From cda1e7eba41a8997c51a53a3c0a16716a4ff43ce Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Mon, 7 Dec 2015 18:12:22 -0500 Subject: [PATCH] Refactor postEditor#insertPost to handle more situations * fix bug in post#cloneRange when range starts in list item * add assert.isPostSimilar, assert.isRenderTreeEqual, assert.isPositionEqual * fix ListSection#clone to use the same tagName for the clonee as the cloner * add PostEditor#insertMarkers method * add private/intimate methods for splitting list items and lists in postEditor Fix #249 #259 --- src/js/editor/post.js | 185 ++-- src/js/editor/post/post-inserter.js | 266 ++++++ src/js/models/_markerable.js | 68 +- src/js/models/_section.js | 1 + src/js/models/list-item.js | 1 + src/js/models/list-section.js | 9 +- src/js/models/marker.js | 22 +- src/js/models/post.js | 45 +- src/js/models/render-tree.js | 2 +- src/js/utils/cursor/position.js | 8 + tests/acceptance/editor-copy-paste-test.js | 27 + tests/acceptance/editor-list-test.js | 14 +- tests/helpers/assertions.js | 176 +++- tests/unit/editor/post-test.js | 496 ++++------ tests/unit/editor/post/insert-post-test.js | 995 +++++++++++++++++++++ tests/unit/models/list-section-test.js | 24 + tests/unit/models/markup-section-test.js | 109 ++- tests/unit/models/post-test.js | 83 ++ 18 files changed, 2106 insertions(+), 425 deletions(-) create mode 100644 src/js/editor/post/post-inserter.js create mode 100644 tests/unit/editor/post/insert-post-test.js create mode 100644 tests/unit/models/list-section-test.js diff --git a/src/js/editor/post.js b/src/js/editor/post.js index 1ca1c622d..c9710d0e2 100644 --- a/src/js/editor/post.js +++ b/src/js/editor/post.js @@ -6,6 +6,7 @@ import mixin from '../utils/mixin'; import assert from '../utils/assert'; import { normalizeTagName } from '../utils/dom-utils'; import Range from '../utils/cursor/range'; +import PostInserter from './post/post-inserter'; function isListSectionTagName(tagName) { return tagName === 'ul' || tagName === 'ol'; @@ -606,25 +607,20 @@ class PostEditor { if (section.isCardSection) { return this._splitCardSection(section, position); } else if (section.isListItem) { - let [beforeSection, afterSection] = section.splitAtPosition(position); - this._coalesceMarkers(beforeSection); - this._coalesceMarkers(afterSection); - - let newSections = [beforeSection, afterSection]; - let replacementSections = [beforeSection, afterSection]; - - if (beforeSection.isBlank && section.isBlank) { - const isLastItemInList = section === section.parent.items.tail; + let isLastAndBlank = section.isBlank && !section.next; + if (isLastAndBlank) { + // if is last, replace the item with a blank markup section + let parent = section.parent; + let collection = this.editor.post.sections; + let blank = this.builder.createMarkupSection(); + this.removeSection(section); + this.insertSectionBefore(collection, blank, parent.next); - if (isLastItemInList) { - // when hitting enter in a final empty list item, do not insert a new - // empty item - replacementSections.shift(); - } + return [null, blank]; + } else { + let [pre, post] = this._splitListItem(section, position); + return [pre, post]; } - - this._replaceSection(section, replacementSections); - return newSections; } else { let splitSections = section.splitAtPosition(position); splitSections.forEach(s => this._coalesceMarkers(s)); @@ -720,6 +716,29 @@ class PostEditor { return this.moveSectionBefore(collection, renderedSection, beforeSection); } + insertMarkers(position, markers) { + let { section, offset } = position; + assert('Cannot insert markers at non-markerable position', + section.isMarkerable); + + let edit = section.splitMarkerAtOffset(offset); + edit.removed.forEach(marker => this._scheduleForRemoval(marker)); + + let prevMarker = section.markerBeforeOffset(offset); + markers.forEach(marker => { + section.markers.insertAfter(marker, prevMarker); + offset += marker.length; + prevMarker = marker; + }); + + this._coalesceMarkers(section); + this._markDirty(section); + + let nextPosition = new Position(position.section, offset); + this.setRange(new Range(nextPosition)); + return nextPosition; + } + insertText(position, text) { let section = position.section; if (!section.isMarkerable) { @@ -749,7 +768,8 @@ class PostEditor { let nextNewSection = newSections[0]; if (nextNewSection.isMarkupSection && section.isListItem) { - // put the new section after the ListSection (section.parent) instead of after the ListItem + // put the new section after the ListSection (section.parent) + // instead of after the ListItem collection = section.parent.parent.sections; nextSection = section.parent.next; } @@ -922,6 +942,82 @@ class PostEditor { } } + /** + * Splits the item at the position given. + * If thse position is at the start or end of the item, the pre- or post-item + * will contain a single empty ("") marker. + * @return {Array} the pre-item and post-item on either side of the split + */ + _splitListItem(item, position) { + let { section, offset } = position; + assert('Cannot split list item at position that does not include item', + item === section); + + item.splitMarkerAtOffset(offset); + let prevMarker = item.markerBeforeOffset(offset); + let preItem = this.builder.createListItem(), + postItem = this.builder.createListItem(); + + let currentItem = preItem; + item.markers.forEach(marker => { + currentItem.markers.append(marker.clone()); + if (marker === prevMarker) { + currentItem = postItem; + } + }); + this._replaceSection(item, [preItem, postItem]); + return [preItem, postItem]; + } + + /** + * Splits the list at the position given. + * @return {Array} pre-split list and post-split list, either of which could + * be blank (0-item list) if the position is at the start or end of the list. + * + * Note: Contiguous list sections will be joined in the before_complete queue + * of the postEditor. + */ + _splitListAtPosition(list, position) { + assert('Cannot split list at position not in list', + position.section.parent === list); + + let positionIsMiddle = !position.isHead() && !position.isTail(); + if (positionIsMiddle) { + let item = position.section; + let [pre, post] = // jshint ignore:line + this._splitListItem(item, position); + position = pre.tailPosition(); + } + + let positionIsStart = position.isEqual(list.headPosition()), + positionIsEnd = position.isEqual(list.tailPosition()); + + if (positionIsStart || positionIsEnd) { + let blank = this.builder.createListSection(list.tagName); + let reference = position.isEqual(list.headPosition()) ? list : + list.next; + let collection = this.editor.post.sections; + this.insertSectionBefore(collection, blank, reference); + + let lists = positionIsStart ? [blank, list] : [list, blank]; + return lists; + } else { + let preList = this.builder.createListSection(list.tagName), + postList = this.builder.createListSection(list.tagName); + let preItem = position.section; + let currentList = preList; + list.items.forEach(item => { + currentList.items.append(item.clone()); + if (item === preItem) { + currentList = postList; + } + }); + + this._replaceSection(list, [preList, postList]); + return [preList, postList]; + } + } + /** * @return Array of [prev, mid, next] lists. `prev` and `next` can * be blank, depending on the position of `item`. `mid` will always @@ -973,10 +1069,11 @@ class PostEditor { } _changeSectionFromListItem(section, newTagName) { - assert('Must pass list item to `_changeSectionFromListItem`', section.isListItem); - let { builder } = this; + assert('Must pass list item to `_changeSectionFromListItem`', + section.isListItem); + let listSection = section.parent; - let markupSection = builder.createMarkupSection(newTagName); + let markupSection = this.builder.createMarkupSection(newTagName); markupSection.join(section); let [prev, mid, next] = this._splitListAtItem(listSection, section); // jshint ignore:line @@ -1061,46 +1158,12 @@ class PostEditor { * @method insertPost * @param {Position} position * @param {Post} post - * @return {Position} position at end of inserted content * @private */ insertPost(position, newPost) { - if (newPost.isBlank) { - return position; - } - const post = this.editor.post; - - let [preSplit, postSplit] = this.splitSection(position); - let nextPosition = position.clone(); - - newPost.sections.forEach((section, index) => { - if (index === 0 && preSplit.canJoin(section)) { - preSplit.join(section); - this._markDirty(preSplit); - - nextPosition = preSplit.tailPosition(); - } else { - section = section.clone(); - this.insertSectionBefore(post.sections, section, postSplit); - - nextPosition = section.tailPosition(); - } - }); - - if (postSplit.isBlank) { - this.removeSection(postSplit); - } - - if (preSplit.canJoin(postSplit) && preSplit.next === postSplit) { - nextPosition = preSplit.tailPosition(); - - preSplit.join(postSplit); - this._markDirty(preSplit); - this.removeSection(postSplit); - } else if (preSplit.isBlank) { - this.removeSection(preSplit); - } - + let post = this.editor.post; + let inserter = new PostInserter(this, post); + let nextPosition = inserter.insert(position, newPost); return nextPosition; } @@ -1184,16 +1247,14 @@ class PostEditor { } /** - * Flush any work on the queue. `editor.run` already does this, calling this + * Flush any work on the queue. `editor.run` already does this. Calling this * method directly should not be needed outside `editor.run`. * * @method complete * @private */ complete() { - if (this._didComplete) { - throw new Error('Post editing can only be completed once'); - } + assert('Post editing can only be completed once', !this._didComplete); this.runCallbacks(CALLBACK_QUEUES.BEFORE_COMPLETE); this._didComplete = true; diff --git a/src/js/editor/post/post-inserter.js b/src/js/editor/post/post-inserter.js new file mode 100644 index 000000000..192859c83 --- /dev/null +++ b/src/js/editor/post/post-inserter.js @@ -0,0 +1,266 @@ +import assert from 'mobiledoc-kit/utils/assert'; +import { + MARKUP_SECTION_TYPE, + LIST_SECTION_TYPE, + POST_TYPE, + CARD_TYPE, + IMAGE_SECTION_TYPE, + LIST_ITEM_TYPE, +} from 'mobiledoc-kit/models/types'; +import Range from 'mobiledoc-kit/utils/cursor/range'; + +const MARKERABLE = 'markerable', + NESTED_MARKERABLE = 'nested_markerable', + NON_MARKERABLE = 'non_markerable'; + +class Visitor { + constructor(inserter, cursorPosition) { + let { postEditor, post } = inserter; + this.postEditor = postEditor; + this._post = post; + this.cursorPosition = cursorPosition; + this.builder = this.postEditor.builder; + + this._hasInsertedFirstLeafSection = false; + } + + get cursorPosition() { + return this._cursorPosition; + } + + set cursorPosition(position) { + this._cursorPosition = position; + this.postEditor.setRange(new Range(position)); + } + + visit(node) { + let method = node.type; + assert(`Cannot visit node of type ${node.type}`, !!this[method]); + this[method](node); + } + + _canMergeSection(section) { + if (this._hasInsertedFirstLeafSection) { + return false; + } else { + return this._isMarkerable && section.isMarkerable; + } + } + + get _isMarkerable() { + return this.cursorSection.isMarkerable; + } + + get cursorSection() { + return this.cursorPosition.section; + } + + get cursorOffset() { + return this.cursorPosition.offset; + } + + get _isNested() { + return this.cursorSection.isNested; + } + + [POST_TYPE](node) { + if (this.cursorSection.isBlank && !this._isNested) { + // replace blank section with entire post + let newSections = node.sections.map(s => s.clone()); + this._replaceSection(this.cursorSection, newSections); + this.cursorPosition = newSections[newSections.length - 1].tailPosition(); + } else { + node.sections.forEach(section => this.visit(section)); + } + } + + [MARKUP_SECTION_TYPE](node) { + this[MARKERABLE](node); + } + + [LIST_SECTION_TYPE](node) { + let hasNext = !!node.next; + node.items.forEach(item => this.visit(item)); + + if (this._isNested && hasNext) { + this._breakNestedAtCursor(); + } + } + + [LIST_ITEM_TYPE](node) { + this[NESTED_MARKERABLE](node); + } + + [CARD_TYPE](node) { + this[NON_MARKERABLE](node); + } + + [IMAGE_SECTION_TYPE](node) { + this[NON_MARKERABLE](node); + } + + [NON_MARKERABLE](section) { + if (this._isNested) { + this._breakNestedAtCursor(); + } else if (!this.cursorSection.isBlank) { + this._breakAtCursor(); + } + + this._insertLeafSection(section); + } + + [MARKERABLE](section) { + if (this._canMergeSection(section)) { + this._mergeSection(section); + } else { + this._breakAtCursor(); + this._insertLeafSection(section); + } + } + + [NESTED_MARKERABLE](section) { + if (this._canMergeSection(section)) { + this._mergeSection(section); + return; + } + + section = this._isNested ? section : this._wrapNestedSection(section); + this._breakAtCursor(); + this._insertLeafSection(section); + } + + // break out of a nested cursor position + _breakNestedAtCursor() { + assert('Cannot call _breakNestedAtCursor if not nested', + this._isNested); + + let parent = this.cursorSection.parent, + cursorAtEndOfList = this.cursorPosition.isEqual(parent.tailPosition()); + + if (cursorAtEndOfList) { + let blank = this.builder.createMarkupSection(); + this._insertSectionAfter(blank, parent); + this.cursorPosition = blank.headPosition(); + } else { + let [pre, blank, post] = this._breakListAtCursor(); // jshint ignore:line + this.cursorPosition = blank.headPosition(); + } + } + + _breakListAtCursor() { + assert('Cannot _splitParentSection if cursor position is not nested', + this._isNested); + + let list = this.cursorSection.parent, + position = this.cursorPosition, + blank = this.builder.createMarkupSection(); + let [pre, post] = this.postEditor._splitListAtPosition(list, position); + + let collection = this._post.sections, + reference = post; + this.postEditor.insertSectionBefore(collection, blank, reference); + return [pre, blank, post]; + } + + _insertSectionAfter(section, parent) { + assert('Cannot _insertSectionAfter nested section', !parent.isNested); + let reference = parent.next; + let collection = this._post.sections; + this.postEditor.insertSectionBefore(collection, section, reference); + } + + _wrapNestedSection(section) { + let tagName = section.parent.tagName; + let parent = this.builder.createListSection(tagName); + parent.items.append(section.clone()); + return parent; + } + + _mergeSection(section) { + assert('Can only merge markerable sections', + this._isMarkerable && section.isMarkerable); + this._hasInsertedFirstLeafSection = true; + + let markers = section.markers.map(m => m.clone()); + let position = this.postEditor.insertMarkers(this.cursorPosition, markers); + + this.cursorPosition = position; + } + + // Can be called to add a line break when in a nested section or a parent + // section. + _breakAtCursor() { + if (this.cursorSection.isBlank) { + return; + } else if (this._isMarkerable) { + this._breakMarkerableAtCursor(); + } else { + this._breakNonMarkerableAtCursor(); + } + } + + // Inserts a blank section before/after the cursor, + // depending on cursor position. + _breakNonMarkerableAtCursor() { + let collection = this._post.sections, + blank = this.builder.createMarkupSection(), + reference = this.cursorPosition.isHead() ? this.cursorSection : + this.cursorSection.next; + this.postEditor.insertSectionBefore(collection, blank, reference); + this.cursorPosition = blank.tailPosition(); + } + + _breakMarkerableAtCursor() { + let [pre, post] = // jshint ignore:line + this.postEditor.splitSection(this.cursorPosition); + this.cursorPosition = pre.tailPosition(); + } + + _replaceSection(section, newSections) { + assert('Cannot replace section that does not have parent.sections', + section.parent && section.parent.sections); + assert('Must pass enumerable to _replaceSection', !!newSections.forEach); + + let collection = section.parent.sections, + reference = section.next; + this.postEditor.removeSection(section); + newSections.forEach(_newSection => { + this.postEditor.insertSectionBefore(collection, _newSection, reference); + }); + } + + _insertLeafSection(section) { + assert('Can only _insertLeafSection when cursor is at end of section', + this.cursorPosition.isTail()); + + this._hasInsertedFirstLeafSection = true; + section = section.clone(); + + if (this.cursorSection.isBlank) { + assert('Cannot insert leaf non-markerable section when cursor is nested', + !(section.isMarkerable && this._isNested)); + this._replaceSection(this.cursorSection, [section]); + } else if (this.cursorSection.next && this.cursorSection.next.isBlank) { + this._replaceSection(this.cursorSection.next, [section]); + } else { + let reference = this.cursorSection.next; + let collection = this.cursorSection.parent.sections; + this.postEditor.insertSectionBefore(collection, section, reference); + } + + this.cursorPosition = section.tailPosition(); + } +} + +export default class Inserter { + constructor(postEditor, post) { + this.postEditor = postEditor; + this.post = post; + } + + insert(cursorPosition, newPost) { + let visitor = new Visitor(this, cursorPosition); + visitor.visit(newPost); + return visitor.cursorPosition; + } +} diff --git a/src/js/models/_markerable.js b/src/js/models/_markerable.js index 85ff44c05..686a728b0 100644 --- a/src/js/models/_markerable.js +++ b/src/js/models/_markerable.js @@ -4,6 +4,7 @@ import Set from '../utils/set'; import LinkedList from '../utils/linked-list'; import Section from './_section'; import Position from '../utils/cursor/position'; +import assert from '../utils/assert'; export default class Markerable extends Section { constructor(type, tagName, markers=[]) { @@ -11,7 +12,11 @@ export default class Markerable extends Section { this.isMarkerable = true; this.tagName = tagName; this.markers = new LinkedList({ - adoptItem: m => m.section = m.parent = this, + adoptItem: m => { + assert(`Cannot insert non-marker into markerable (was: ${m.type})`, + m.isMarker); + m.section = m.parent = this; + }, freeItem: m => m.section = m.parent = null }); @@ -111,18 +116,40 @@ export default class Markerable extends Section { * there is now a marker boundary at that offset (useful for later applying * a markup to a range) * @param {Number} sectionOffset The offset relative to start of this section - * @return {EditObject} An edit object with 'removed' and 'added' keys with arrays of Markers + * @return {EditObject} An edit object with 'removed' and 'added' keys with arrays of Markers. The added markers may be blank. + * After calling `splitMarkerAtOffset(offset)`, there will always be a valid + * result returned from `markerBeforeOffset(offset)`. */ splitMarkerAtOffset(sectionOffset) { - const edit = {removed:[], added:[]}; - const {marker,offset} = this.markerPositionAtOffset(sectionOffset); - if (!marker) { return edit; } - - const newMarkers = filter(marker.split(offset), m => !m.isEmpty); - this.markers.splice(marker, 1, newMarkers); - - edit.removed = [marker]; - edit.added = newMarkers; + assert('Cannot splitMarkerAtOffset when offset is > length', + sectionOffset <= this.length); + let markerOffset; + let len = 0; + let currentMarker = this.markers.head; + let edit = {added: [], removed: []}; + + if (!currentMarker) { + let blankMarker = this.builder.createMarker(); + this.markers.prepend(blankMarker); + edit.added.push(blankMarker); + } else { + while (currentMarker) { + len += currentMarker.length; + if (len === sectionOffset) { + // nothing to do, there is a gap at the requested offset + break; + } else if (len > sectionOffset) { + markerOffset = currentMarker.length - (len - sectionOffset); + let newMarkers = currentMarker.splitAtOffset(markerOffset); + edit.added.push(...newMarkers); + edit.removed.push(currentMarker); + this.markers.splice(currentMarker, 1, newMarkers); + break; + } else { + currentMarker = currentMarker.next; + } + } + } return edit; } @@ -132,6 +159,25 @@ export default class Markerable extends Section { return this.splitAtMarker(marker, offsetInMarker); } + // returns the marker just before this offset. + // It is an error to call this method with an offset that is in the middle + // of a marker. + markerBeforeOffset(sectionOffset) { + let len = 0; + let currentMarker = this.markers.head; + + while (currentMarker) { + len += currentMarker.length; + if (len === sectionOffset) { + return currentMarker; + } else { + assert('markerBeforeOffset called with sectionOffset not between markers', + len < sectionOffset); + currentMarker = currentMarker.next; + } + } + } + markerPositionAtOffset(offset) { let currentOffset = 0; let currentMarker; diff --git a/src/js/models/_section.js b/src/js/models/_section.js index 0b758e094..493eb8fd7 100644 --- a/src/js/models/_section.js +++ b/src/js/models/_section.js @@ -17,6 +17,7 @@ export default class Section extends LinkedItem { assert('Cannot create section without type', !!type); this.type = type; this.isMarkerable = false; + this.isNested = false; } set tagName(val) { diff --git a/src/js/models/list-item.js b/src/js/models/list-item.js index e5c243f75..a68183cfd 100644 --- a/src/js/models/list-item.js +++ b/src/js/models/list-item.js @@ -13,6 +13,7 @@ export default class ListItem extends Markerable { constructor(tagName, markers=[]) { super(LIST_ITEM_TYPE, tagName, markers); this.isListItem = true; + this.isNested = true; } isValidTagName(normalizedTagName) { diff --git a/src/js/models/list-section.js b/src/js/models/list-section.js index 30b52d790..122abd6a5 100644 --- a/src/js/models/list-section.js +++ b/src/js/models/list-section.js @@ -3,6 +3,7 @@ import { forEach, contains } from '../utils/array-utils'; import { LIST_SECTION_TYPE } from './types'; import Section from './_section'; import { normalizeTagName } from '../utils/dom-utils'; +import assert from '../utils/assert'; export const VALID_LIST_SECTION_TAGNAMES = [ 'ul', 'ol' @@ -17,7 +18,11 @@ export default class ListSection extends Section { this.isListSection = true; this.items = new LinkedList({ - adoptItem: i => i.section = i.parent = this, + adoptItem: i => { + assert(`Cannot insert non-list-item to list (is: ${i.type})`, + i.isListItem); + i.section = i.parent = this; + }, freeItem: i => i.section = i.parent = null }); this.sections = this.items; @@ -46,7 +51,7 @@ export default class ListSection extends Section { } clone() { - let newSection = this.builder.createListSection(); + let newSection = this.builder.createListSection(this.tagName); forEach(this.items, i => newSection.items.append(i.clone())); return newSection; } diff --git a/src/js/models/marker.js b/src/js/models/marker.js index b4490c4f3..30e83a033 100644 --- a/src/js/models/marker.js +++ b/src/js/models/marker.js @@ -1,8 +1,8 @@ import { MARKER_TYPE } from './types'; - import { normalizeTagName } from '../utils/dom-utils'; import { detect, commonItemLength, forEach, filter } from '../utils/array-utils'; import LinkedItem from '../utils/linked-item'; +import assert from '../utils/assert'; const Marker = class Marker extends LinkedItem { constructor(value='', markups=[]) { @@ -10,6 +10,7 @@ const Marker = class Marker extends LinkedItem { this.value = value; this.markups = []; this.type = MARKER_TYPE; + this.isMarker = true; markups.forEach(m => this.addMarkup(m)); } @@ -116,6 +117,25 @@ const Marker = class Marker extends LinkedItem { return markers; } + /** + * @return {Array} 2 markers either or both of which could be blank + */ + splitAtOffset(offset) { + assert('Cannot split a marker at an offset > its length', + offset <= this.length); + let { value, builder } = this; + + let pre = builder.createMarker(value.substring(0, offset)); + let post = builder.createMarker(value.substring(offset)); + + this.markups.forEach(markup => { + pre.addMarkup(markup); + post.addMarkup(markup); + }); + + return [pre, post]; + } + get openedMarkups() { let count = 0; if (this.prev) { diff --git a/src/js/models/post.js b/src/js/models/post.js index 7f0a3c8d3..b97639fb4 100644 --- a/src/js/models/post.js +++ b/src/js/models/post.js @@ -3,6 +3,7 @@ import LinkedList from 'mobiledoc-kit/utils/linked-list'; import { forEach, compact } from 'mobiledoc-kit/utils/array-utils'; import Set from 'mobiledoc-kit/utils/set'; import mobiledocRenderers from 'mobiledoc-kit/renderers/mobiledoc'; +import Range from 'mobiledoc-kit/utils/cursor/range'; export default class Post { constructor() { @@ -99,21 +100,10 @@ export default class Post { return markups.toArray(); } - // FIXME if range.head is a listItem this will not work properly - walkPostSections(range, callback) { - const {head, tail} = range; - - let currentSection = head.section; - - while (currentSection) { - callback(currentSection); - - if (currentSection === tail.section) { - break; - } else { - currentSection = currentSection.next; - } - } + walkAllLeafSections(callback) { + let range = new Range(this.sections.head.headPosition(), + this.sections.tail.tailPosition()); + return this.walkLeafSections(range, callback); } walkLeafSections(range, callback) { @@ -217,10 +207,27 @@ export default class Post { const post = this.builder.createPost(); const { builder } = this; - this.walkPostSections(range, section => { + let sectionParent = post, + listParent = null; + this.walkLeafSections(range, section => { let newSection; if (section.isMarkerable) { - newSection = builder.createMarkupSection(section.tagName); + if (section.isListItem) { + if (listParent) { + sectionParent = null; + } else { + listParent = builder.createListSection(section.parent.tagName); + post.sections.append(listParent); + sectionParent = null; + } + newSection = builder.createListItem(); + listParent.items.append(newSection); + } else { + listParent = null; + sectionParent = post; + newSection = builder.createMarkupSection(section.tagName); + } + let currentRange = range.trimTo(section); forEach( section.markersFor(currentRange.headSectionOffset, currentRange.tailSectionOffset), @@ -229,7 +236,9 @@ export default class Post { } else { newSection = section.clone(); } - post.sections.append(newSection); + if (sectionParent) { + sectionParent.sections.append(newSection); + } }); return mobiledocRenderers.render(post); } diff --git a/src/js/models/render-tree.js b/src/js/models/render-tree.js index 0cbb60cd7..924461005 100644 --- a/src/js/models/render-tree.js +++ b/src/js/models/render-tree.js @@ -33,7 +33,7 @@ export default class RenderTree { } /** * @param {DOMNode} element - * Walk up from the element until we find a renderNode element + * Walk up from the dom element until we find a renderNode element */ findRenderNodeFromElement(element, conditionFn=()=>true) { let renderNode; diff --git a/src/js/utils/cursor/position.js b/src/js/utils/cursor/position.js index 6c890912f..65f556d53 100644 --- a/src/js/utils/cursor/position.js +++ b/src/js/utils/cursor/position.js @@ -68,6 +68,14 @@ const Position = class Position { this.offset === position.offset; } + isHead() { + return this.isEqual(this.section.headPosition()); + } + + isTail() { + return this.isEqual(this.section.tailPosition()); + } + move(direction) { switch (direction) { case DIRECTION.BACKWARD: diff --git a/tests/acceptance/editor-copy-paste-test.js b/tests/acceptance/editor-copy-paste-test.js index 212df5506..49fe16473 100644 --- a/tests/acceptance/editor-copy-paste-test.js +++ b/tests/acceptance/editor-copy-paste-test.js @@ -311,3 +311,30 @@ test('pasting when on the end of a card is blocked', (assert) => { ] ], 'no paste has occurred'); }); + +// see https://github.com/bustlelabs/mobiledoc-kit/issues/249 +test('pasting when replacing a list item works', (assert) => { + let mobiledoc = Helpers.mobiledoc.build( + ({post, listSection, listItem, markupSection, marker}) => { + return post([ + markupSection('p', [marker('X')]), + listSection('ul', [ + listItem([marker('Y')]) + ]) + ]); + }); + + editor = new Editor({mobiledoc, cards}); + editor.render(editorElement); + + assert.hasElement('#editor li:contains(Y)', 'precond: has li with Y'); + + Helpers.dom.selectText('X', editorElement); + Helpers.dom.triggerCopyEvent(editor); + + Helpers.dom.selectText('Y', editorElement); + Helpers.dom.triggerPasteEvent(editor); + + assert.hasElement('#editor li:contains(X)', 'replaces Y with X in li'); + assert.hasNoElement('#editor li:contains(Y)', 'li with Y is gone'); +}); diff --git a/tests/acceptance/editor-list-test.js b/tests/acceptance/editor-list-test.js index 5de9c2040..15fd55531 100644 --- a/tests/acceptance/editor-list-test.js +++ b/tests/acceptance/editor-list-test.js @@ -135,6 +135,7 @@ test('can split list item with ', (assert) => { }); test('can hit enter at end of list item to add new item', (assert) => { + let done = assert.async(); createEditorWithListMobiledoc(); const li = $('#editor li:contains(first item)')[0]; @@ -148,13 +149,16 @@ test('can hit enter at end of list item to add new item', (assert) => { assert.equal(newLi.text(), '', 'new li has no text'); Helpers.dom.insertText(editor, 'X'); - assert.hasElement('#editor li:contains(X)', 'text goes in right spot'); + setTimeout(() => { + assert.hasElement('#editor li:contains(X)', 'text goes in right spot'); - const liCount = $('#editor li').length; - Helpers.dom.triggerEnter(editor); - Helpers.dom.triggerEnter(editor); + const liCount = $('#editor li').length; + Helpers.dom.triggerEnter(editor); + Helpers.dom.triggerEnter(editor); - assert.equal($('#editor li').length, liCount+2, 'adds two new empty list items'); + assert.equal($('#editor li').length, liCount+2, 'adds two new empty list items'); + done(); + }); }); test('hitting enter to add list item, deleting to remove it, adding new list item, exiting list and typing', (assert) => { diff --git a/tests/helpers/assertions.js b/tests/helpers/assertions.js index e1fdda7f7..5093dd905 100644 --- a/tests/helpers/assertions.js +++ b/tests/helpers/assertions.js @@ -1,15 +1,123 @@ /* global QUnit, $ */ import DOMHelper from './dom'; +import mobiledocRenderers from 'mobiledoc-kit/renderers/mobiledoc'; +import { + MARKUP_SECTION_TYPE, + LIST_SECTION_TYPE, + MARKUP_TYPE, + MARKER_TYPE, + POST_TYPE, + LIST_ITEM_TYPE, + CARD_TYPE, + IMAGE_SECTION_TYPE +} from 'mobiledoc-kit/models/types'; + +function comparePostNode(actual, expected, assert, path='root', deepCompare=false) { + if (!actual || !expected) { + assert.ok(!!actual, `missing actual post node at ${path}`); + assert.ok(!!expected, `missing expected post node at ${path}`); + return; + } + if (actual.type !== expected.type) { + assert.push(false, actual.type, expected.type, `wrong type at ${path}`); + } + + switch (actual.type) { + case POST_TYPE: + if (actual.sections.length !== expected.sections.length) { + assert.equal(actual.sections.length, expected.sections.length, + `wrong sections for post`); + } + if (deepCompare) { + actual.sections.forEach((section, index) => { + comparePostNode(section, expected.sections.objectAt(index), + assert, `${path}:${index}`, deepCompare); + }); + } + break; + case MARKER_TYPE: + if (actual.value !== expected.value) { + assert.equal(actual.value, expected.value, `wrong value at ${path}`); + } + if (actual.markups.length !== expected.markups.length) { + assert.equal(actual.markups.length, expected.markups.length, + `wrong markups at ${path}`); + } + if (deepCompare) { + actual.markups.forEach((markup, index) => { + comparePostNode(markup, expected.markups[index], + assert, `${path}:${index}`, deepCompare); + }); + } + break; + case MARKUP_SECTION_TYPE: + case LIST_ITEM_TYPE: + if (actual.tagName !== expected.tagName) { + assert.equal(actual.tagName, expected.tagName, `wrong tagName at ${path}`); + } + if (actual.markers.length !== expected.markers.length) { + assert.equal(actual.markers.length, expected.markers.length, + `wrong markers at ${path}`); + } + if (deepCompare) { + actual.markers.forEach((marker, index) => { + comparePostNode(marker, expected.markers.objectAt(index), + assert, `${path}:${index}`, deepCompare); + }); + } + break; + case CARD_TYPE: + if (actual.name !== expected.name) { + assert.equal(actual.name, expected.name, `wrong card name at ${path}`); + } + if (!QUnit.equiv(actual.payload, expected.payload)) { + assert.deepEqual(actual.payload, expected.payload, + `wrong card payload at ${path}`); + } + break; + case LIST_SECTION_TYPE: + if (actual.items.length !== expected.items.length) { + assert.equal(actual.items.length, expected.items.length, + `wrong items at ${path}`); + } + if (deepCompare) { + actual.items.forEach((item, index) => { + comparePostNode(item, expected.items.objectAt(index), + assert, `${path}:${index}`, deepCompare); + }); + } + break; + case IMAGE_SECTION_TYPE: + if (actual.src !== expected.src) { + assert.equal(actual.src, expected.src, `wrong image src at ${path}`); + } + break; + case MARKUP_TYPE: + if (actual.tagName !== expected.tagName) { + assert.equal(actual.tagName, expected.tagName, + `wrong tagName at ${path}`); + } + if (!QUnit.equiv(actual.attributes, expected.attributes)) { + assert.deepEqual(actual.attributes, expected.attributes, + `wrong attributes at ${path}`); + } + break; + default: + throw new Error('wrong type :' + actual.type); + } +} export default function registerAssertions() { - QUnit.assert.hasElement = function(selector, message=`hasElement "${selector}"`) { + QUnit.assert.hasElement = function(selector, + message=`hasElement "${selector}"`) { let found = $(selector); this.push(found.length > 0, found.length, selector, message); return found; }; - QUnit.assert.hasNoElement = function(selector, message=`hasNoElement "${selector}"`) { + QUnit.assert.hasNoElement = function(selector, + message=`hasNoElement "${selector}"`) { let found = $(selector); this.push(found.length === 0, found.length, selector, message); return found; @@ -23,7 +131,69 @@ export default function registerAssertions() { message); }; - QUnit.assert.inArray = function(element, array, message=`has "${element}" in "${array}"`) { + QUnit.assert.inArray = function(element, array, + message=`has "${element}" in "${array}"`) { QUnit.assert.ok(array.indexOf(element) !== -1, message); }; + + QUnit.assert.postIsSimilar = function(post, expected, postName='post') { + comparePostNode(post, expected, this, postName, true); + let mobiledoc = mobiledocRenderers.render(post), + expectedMobiledoc = mobiledocRenderers.render(expected); + this.deepEqual(mobiledoc, expectedMobiledoc, + `${postName} is similar to expected`); + }; + + QUnit.assert.renderTreeIsEqual = function(renderTree, expectedPost) { + if (renderTree.rootNode.isDirty) { + this.ok(false, 'renderTree is dirty'); + return; + } + + expectedPost.sections.forEach((section, index) => { + let renderNode = renderTree.rootNode.childNodes.objectAt(index); + let path = `post:${index}`; + + let compareChildren = (parentPostNode, parentRenderNode, path) => { + let children = parentPostNode.markers || + parentPostNode.items || + []; + + if (children.length !== parentRenderNode.childNodes.length) { + this.equal(parentRenderNode.childNodes.length, children.length, + `wrong child render nodes at ${path}`); + return; + } + + children.forEach((child, index) => { + let renderNode = parentRenderNode.childNodes.objectAt(index); + + comparePostNode(child, renderNode && renderNode.postNode, + this, `${path}:${index}`, false); + compareChildren(child, renderNode, `${path}:${index}`); + }); + }; + + comparePostNode(section, renderNode.postNode, this, path, false); + compareChildren(section, renderNode, path); + }); + + this.ok(true, 'renderNode is similar'); + }; + + QUnit.assert.positionIsEqual = function(position, expected, + message=`position is equal`) { + if (position.section !== expected.section) { + this.push(false, + `${position.section.type}:${position.section.tagName}`, + `${expected.section.type}:${expected.section.tagName}`, + `incorrect position section`); + } else if (position.offset !== expected.offset) { + this.push(false, position.offset, expected.offset, + `incorrect position offset`); + } else { + this.push(true, position, expected, message); + } + }; + } diff --git a/tests/unit/editor/post-test.js b/tests/unit/editor/post-test.js index 173ef9389..eea5b5d70 100644 --- a/tests/unit/editor/post-test.js +++ b/tests/unit/editor/post-test.js @@ -7,10 +7,6 @@ import { DIRECTION } from 'mobiledoc-kit/utils/key'; import PostNodeBuilder from 'mobiledoc-kit/models/post-node-builder'; import Range from 'mobiledoc-kit/utils/cursor/range'; import Position from 'mobiledoc-kit/utils/cursor/position'; -import { - LIST_SECTION_TYPE, - CARD_TYPE -} from 'mobiledoc-kit/models/types'; const { FORWARD } = DIRECTION; @@ -43,6 +39,18 @@ function renderBuiltAbstract(post) { return mockEditor; } +let renderedRange; +function buildEditorWithMobiledoc(builderFn) { + let mobiledoc = Helpers.mobiledoc.build(builderFn); + let unknownCardHandler = () => {}; + editor = new Editor({mobiledoc, unknownCardHandler}); + editor.render(editorElement); + editor.renderRange = function() { + renderedRange = this.range; + }; + return editor; +} + module('Unit: PostEditor with mobiledoc', { beforeEach() { editorElement = $('#editor')[0]; @@ -920,322 +928,6 @@ test('moveSectionDown moves it down', (assert) => { 'moveSectionDown is no-op when card is at bottom'); }); -test('#insertPost single section, insert at start', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker}) => { - post1 = post([markupSection('p', [marker('abc')])]); - post2 = post([markupSection('p', [marker('def')])]); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, 0); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 1, '1 section'); - assert.equal(post1.sections.head.text, 'defabc', 'inserts text'); - - assert.ok(nextPosition.section === post1.sections.head, - 'nextPosition.section is correct'); - assert.equal(nextPosition.offset, 3, - 'nextPosition.offset is correct'); -}); - -test('#insertPost single section, insert at end', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker}) => { - post1 = post([markupSection('p', [marker('abc')])]); - post2 = post([markupSection('p', [marker('def')])]); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, post1.sections.head.length); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 1, '1 section'); - assert.equal(post1.sections.head.text, 'abcdef', 'inserts text'); - - assert.ok(nextPosition.section === post1.sections.head, - 'nextPosition.section is correct'); - assert.equal(nextPosition.offset, 6, - 'nextPosition.offset is correct'); -}); - -test('#insertPost single section, insert at middle', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker}) => { - post1 = post([markupSection('p', [marker('abc')])]); - post2 = post([markupSection('p', [marker('def')])]); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, 1); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 1, '1 section'); - assert.equal(post1.sections.head.text, 'adefbc', 'inserts text'); - - assert.ok(nextPosition.section === post1.sections.head, - 'nextPosition.section is correct'); - assert.equal(nextPosition.offset, 4, - 'nextPosition.offset is correct'); -}); - -test('#insertPost multiple sections, insert at start', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker}) => { - post1 = post([markupSection('p', [marker('abc')])]); - post2 = post([ - markupSection('p', [marker('123')]), - markupSection('p', [marker('456')]) - ]); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, 0); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 3, '3 sections'); - assert.equal(post1.sections.objectAt(0).text, '123', - 'inserts text in section 1'); - assert.equal(post1.sections.objectAt(1).text, '456', - 'inserts text in section 2'); - assert.equal(post1.sections.objectAt(2).text, 'abc', - 'inserts text in section 3'); - - assert.ok(nextPosition.section === post1.sections.objectAt(1), - 'nextPosition section is correct'); - assert.equal(nextPosition.offset, post1.sections.objectAt(1).length, - 'nextPosition offset is correct'); -}); - -test('#insertPost multiple sections, insert at end', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker}) => { - post1 = post([markupSection('p', [marker('abc')])]); - post2 = post([ - markupSection('p', [marker('123')]), - markupSection('p', [marker('456')]) - ]); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, post1.sections.head.length); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 2, '2 sections'); - assert.equal(post1.sections.head.text, 'abc123', 'inserts text in section 1'); - assert.equal(post1.sections.tail.text, '456', 'inserts text in section 2'); - - assert.ok(nextPosition.section === post1.sections.tail, - 'nextPosition.section is correct'); - assert.equal(nextPosition.offset, post1.sections.tail.length, - 'nextPosition.offset is correct'); -}); - -test('#insertPost multiple sections, insert at middle', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker}) => { - post1 = post([markupSection('p', [marker('abc')])]); - post2 = post([ - markupSection('p', [marker('123')]), - markupSection('p', [marker('456')]) - ]); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, 1); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 3, '3 sections'); - assert.equal(post1.sections.objectAt(0).text, 'a123', - 'inserts text in section 1'); - assert.equal(post1.sections.objectAt(1).text, '456', - 'inserts text in section 2'); - assert.equal(post1.sections.objectAt(2).text, 'bc', - 'inserts text in section 3'); - assert.ok(nextPosition.section === post1.sections.objectAt(1), - 'nextPosition.section is correct'); - assert.equal(nextPosition.offset, post1.sections.objectAt(1).length, - 'nextPosition.offset is correct'); -}); - -test('#insertPost insert empty post does nothing', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker}) => { - post1 = post([markupSection('p', [marker('abc')])]); - post2 = post(); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, 1); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 1, 'still 1 section'); - assert.equal(post1.sections.head.text, 'abc', 'same section text'); - assert.ok(nextPosition.section === post1.sections.head, - 'nextPosition.section correct'); - assert.equal(nextPosition.offset, 1, 'nextPosition.offset correct'); -}); - -test('#insertPost can insert a single list section', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker, listSection, listItem}) => { - post1 = post([markupSection('p', [marker('abc')])]); - post2 = post([listSection('ul', [ - listItem([marker('123')]), - listItem([marker('4')]) - ])]); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, 1); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 3, '3 sections'); - assert.equal(post1.sections.head.text, 'a', 'head section truncated'); - let section2 = post1.sections.objectAt(1); - assert.equal(section2.type, LIST_SECTION_TYPE, 'second section is list section'); - assert.equal(section2.items.length, 2, '2 list items'); - assert.equal(section2.items.objectAt(0).text, '123'); - assert.equal(section2.items.objectAt(1).text, '4'); - - let section3 = post1.sections.objectAt(2); - assert.equal(section3.text, 'bc', 'last section text is correct'); - - assert.ok(nextPosition.section === section2.items.tail, - 'nextPosition.section correct'); - assert.equal(nextPosition.offset, section2.items.tail.length, - 'nextPosition.offset correct'); -}); - -test('#insertPost can insert a single cardSection', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker, cardSection}) => { - post1 = post([markupSection('p', [marker('abc')])]); - post2 = post([cardSection('test-card')]); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, 1); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 3, '3 sections'); - assert.equal(post1.sections.objectAt(0).text, 'a', 'first section split'); - let section2 = post1.sections.objectAt(1); - assert.equal(section2.type, CARD_TYPE, '2nd section is card section'); - assert.equal(section2.name, 'test-card', '2nd section is test-card'); - let section3 = post1.sections.objectAt(2); - assert.equal(section3.text, 'bc', '3rd section split'); - - assert.ok(nextPosition.section === section2, - 'nextPosition.section is card'); -}); - -test('#insertPost in empty section merges markup', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker}) => { - post1 = post([markupSection('p', [marker('')])]); - post2 = post([markupSection('p', [marker('abc')])]); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, 0); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 1, '1 section'); - assert.equal(post1.sections.objectAt(0).text, 'abc', 'correct text'); - assert.ok(nextPosition.section === post1.sections.objectAt(0), - 'nextPosition.section correct'); - assert.equal(nextPosition.offset, 3, - 'nextPosition.offset correct'); -}); - -test('#insertPost in empty section replaces empty section with card', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker, cardSection}) => { - post1 = post([markupSection('p', [marker('')])]); - post2 = post([cardSection('test-card')]); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, 0); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 1, '1 sections'); - let section = post1.sections.objectAt(0); - assert.equal(section.type, CARD_TYPE, 'is card section'); - assert.ok(nextPosition.section === section, - 'nextPosition.section is card section'); -}); - -test('#insertPost in empty section replaces empty section with list', (assert) => { - const build = Helpers.postAbstract.build; - let post1, post2; - build(({post, markupSection, marker, listSection, listItem}) => { - post1 = post([markupSection('p', [marker('')])]); - post2 = post([listSection('ul',[listItem([marker('abc')])])]); - }); - - const mockEditor = renderBuiltAbstract(post1); - const position = new Position(post1.sections.head, 0); - - postEditor = new PostEditor(mockEditor); - let nextPosition = postEditor.insertPost(position, post2); - postEditor.complete(); - - assert.equal(post1.sections.length, 1, '1 section'); - let section = post1.sections.objectAt(0); - assert.equal(section.type, LIST_SECTION_TYPE, 'is list section'); - assert.ok(nextPosition.section === section.items.head, - 'nextPosition.section is list item section'); - assert.equal(nextPosition.offset, section.items.head.length, - 'nextPosition.offset is end of list item'); -}); - test('#toggleSection changes single section to and from tag name', (assert) => { let post = Helpers.postAbstract.build(({post, markupSection}) => { return post([markupSection('p')]); @@ -1732,3 +1424,167 @@ test('#toggleSection joins contiguous list items', (assert) => { ['abc', '123', 'def']); }); +test('#insertMarkers inserts the markers in middle, merging markups', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, markupSection, marker, markup}) => { + toInsert = [ + marker('123', [markup('b')]), marker('456') + ]; + expected = post([ + markupSection('p', [ + marker('abc'), + marker('123', [markup('b')]), + marker('456def') + ])]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abcdef')])]); + }); + let position = new Position(editor.post.sections.head, 'abc'.length); + postEditor = new PostEditor(editor); + postEditor.insertMarkers(position, toInsert); + postEditor.complete(); + + assert.postIsSimilar(editor.post, expected); + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.positionIsEqual( + renderedRange.head, + new Position(editor.post.sections.head, 'abc123456'.length) + ); +}); + +test('#insertMarkers inserts the markers when the markerable has no markers', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, markupSection, marker, markup}) => { + toInsert = [ + marker('123', [markup('b')]), marker('456') + ]; + expected = post([ + markupSection('p', [ + marker('123', [markup('b')]), + marker('456') + ])]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection}) => { + return post([markupSection()]); + }); + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertMarkers(position, toInsert); + postEditor.complete(); + + assert.postIsSimilar(editor.post, expected); + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.positionIsEqual( + renderedRange.head, + new Position(editor.post.sections.head, '123456'.length) + ); +}); + +test('#insertMarkers inserts the markers at start', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, markupSection, marker, markup}) => { + toInsert = [ + marker('123', [markup('b')]), marker('456') + ]; + expected = post([ + markupSection('p', [ + marker('123', [markup('b')]), + marker('456abc') + ])]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertMarkers(position, toInsert); + postEditor.complete(); + + assert.postIsSimilar(editor.post, expected); + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.positionIsEqual( + renderedRange.head, + new Position(editor.post.sections.head, '123456'.length) + ); +}); + +test('#insertMarkers inserts the markers at end', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, markupSection, marker, markup}) => { + toInsert = [ + marker('123', [markup('b')]), marker('456') + ]; + expected = post([ + markupSection('p', [ + marker('abc'), + marker('123', [markup('b')]), + marker('456') + ])]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + let position = editor.post.sections.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertMarkers(position, toInsert); + postEditor.complete(); + + assert.postIsSimilar(editor.post, expected); + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.positionIsEqual( + renderedRange.head, + editor.post.sections.head.tailPosition() + ); +}); + +test('#_splitListItem creates two list items', (assert) => { + let expected = Helpers.postAbstract.build( + ({post, listSection, listItem, marker, markup}) => { + return post([listSection('ul', [ + listItem([marker('abc'), marker('bo', [markup('b')])]), + listItem([marker('ld', [markup('b')])]) + ])]); + }); + editor = buildEditorWithMobiledoc( + ({post, listSection, listItem, marker, markup}) => { + return post([listSection('ul', [ + listItem([marker('abc'), marker('bold', [markup('b')])]) + ])]); + }); + + let item = editor.post.sections.head.items.head; + let position = new Position(item, 'abcbo'.length); + postEditor = new PostEditor(editor); + postEditor._splitListItem(item, position); + postEditor.complete(); + + assert.postIsSimilar(editor.post, expected); + assert.renderTreeIsEqual(editor._renderTree, expected); +}); + +test('#_splitListItem when position is start creates blank list item', (assert) => { + let expected = Helpers.postAbstract.build( + ({post, listSection, listItem, marker}) => { + return post([listSection('ul', [ + listItem([marker('')]), + listItem([marker('abc')]) + ])]); + }); + editor = buildEditorWithMobiledoc( + ({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let item = editor.post.sections.head.items.head; + let position = item.headPosition(); + postEditor = new PostEditor(editor); + postEditor._splitListItem(item, position); + postEditor.complete(); + + assert.postIsSimilar(editor.post, expected); +}); diff --git a/tests/unit/editor/post/insert-post-test.js b/tests/unit/editor/post/insert-post-test.js new file mode 100644 index 000000000..bf44ef65b --- /dev/null +++ b/tests/unit/editor/post/insert-post-test.js @@ -0,0 +1,995 @@ +import PostEditor from 'mobiledoc-kit/editor/post'; +import { Editor } from 'mobiledoc-kit'; +import Helpers from '../../../test-helpers'; +import Position from 'mobiledoc-kit/utils/cursor/position'; + +const { module, test } = Helpers; + +let editor, editorElement, postEditor, renderedRange; +// see https://github.com/bustlelabs/mobiledoc-kit/issues/259 +module('Unit: PostEditor: #insertPost', { + beforeEach() { + editorElement = $('#editor')[0]; + }, + + afterEach() { + if (editor) { + editor.destroy(); + editor = null; + } + } +}); + +function buildEditorWithMobiledoc(builderFn) { + let mobiledoc = Helpers.mobiledoc.build(builderFn); + let unknownCardHandler = () => {}; + editor = new Editor({mobiledoc, unknownCardHandler}); + editor.render(editorElement); + editor.renderRange = function() { + renderedRange = this.range; + }; + return editor; +} + +test('in blank section replaces it', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, listSection, listItem, marker}) => { + toInsert = post([listSection('ul', [listItem([marker('abc')])])]); + expected = post([listSection('ul', [listItem([marker('abc')])])]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection}) => { + return post([markupSection()]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + assert.positionIsEqual(renderedRange.head, + editor.post.sections.head.items.tail.tailPosition(), + 'cursor at end of pasted content'); +}); + +test('in non-markerable at start inserts before', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, cardSection, markupSection, marker}) => { + toInsert = post([markupSection('p', [marker('abc')])]); + expected = post([ + markupSection('p', [marker('abc')]), + cardSection('my-card', {foo:'bar'}) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, cardSection}) => { + return post([cardSection('my-card', {foo:'bar'})]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head; + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-markerable at end inserts after', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, cardSection, markupSection, marker}) => { + toInsert = post([markupSection('p', [marker('abc')])]); + expected = post([ + cardSection('my-card', {foo:'bar'}), + markupSection('p', [marker('abc')]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, cardSection}) => { + return post([cardSection('my-card', {foo:'bar'})]); + }); + + let position = editor.post.sections.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.tail; + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at start and paste is single non-markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, cardSection, markupSection, marker}) => { + toInsert = post([cardSection('my-card', {foo:'bar'})]); + expected = post([ + cardSection('my-card', {foo:'bar'}), + markupSection('p', [marker('abc')]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head; + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at end and paste is single non-markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, cardSection, markupSection, marker}) => { + toInsert = post([cardSection('my-card', {foo:'bar'})]); + expected = post([ + markupSection('p', [marker('abc')]), + cardSection('my-card', {foo:'bar'}) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.tail; // card + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at middle and paste is single non-markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, cardSection, markupSection, marker}) => { + toInsert = post([cardSection('my-card', {foo:'bar'})]); + expected = post([ + markupSection('p', [marker('ab')]), + cardSection('my-card', {foo:'bar'}), + markupSection('p', [marker('c')]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = new Position(editor.post.sections.head, 'ab'.length); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.objectAt(1); + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at start and paste starts with non-markerable and ends with markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, cardSection, markupSection, marker}) => { + toInsert = post([ + cardSection('my-card', {foo:'bar'}), + markupSection('p', [marker('def')]) + ]); + expected = post([ + cardSection('my-card', {foo:'bar'}), + markupSection('p', [marker('def')]), + markupSection('p', [marker('abc')]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.objectAt(1); + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at middle and paste starts with non-markerable and ends with markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, cardSection, markupSection, marker}) => { + toInsert = post([ + cardSection('my-card', {foo:'bar'}), + markupSection('p', [marker('def')]) + ]); + expected = post([ + markupSection('p', [marker('ab')]), + cardSection('my-card', {foo:'bar'}), + markupSection('p', [marker('def')]), + markupSection('p', [marker('c')]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = new Position(editor.post.sections.head, 'ab'.length); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.objectAt(2); + assert.positionIsEqual(renderedRange.head, + new Position(expectedSection, 'def'.length), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at end and paste starts with non-markerable and ends with markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, cardSection, markupSection, marker}) => { + toInsert = post([ + cardSection('my-card', {foo:'bar'}), + markupSection('p', [marker('def')]) + ]); + expected = post([ + markupSection('p', [marker('abc')]), + cardSection('my-card', {foo:'bar'}), + markupSection('p', [marker('def')]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.tail; + assert.positionIsEqual(renderedRange.head, + new Position(expectedSection, 'def'.length), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at start and paste is single non-nested markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, cardSection, markupSection, marker}) => { + toInsert = post([markupSection('p', [marker('123')])]); + expected = post([markupSection('p', [marker('123abc')])]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head; + assert.positionIsEqual(renderedRange.head, + new Position(expectedSection, '123'.length), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at middle and paste is single non-nested markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, cardSection, markupSection, marker}) => { + toInsert = post([markupSection('p', [marker('123')])]); + expected = post([markupSection('p', [marker('ab123c')])]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = new Position(editor.post.sections.head, 'ab'.length); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head; + assert.positionIsEqual(renderedRange.head, + new Position(expectedSection, 'ab123'.length), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at end and paste is single non-nested markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build(({post, cardSection, markupSection, marker}) => { + toInsert = post([markupSection('p', [marker('123')])]); + expected = post([markupSection('p', [marker('abc123')])]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head; + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at start and paste is list with 1 item and no more sections', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([listSection('ul', [listItem([marker('123')])])]); + expected = post([markupSection('p', [marker('123abc')])]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head; + assert.positionIsEqual(renderedRange.head, + new Position(expectedSection, '123'.length), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at middle and paste is list with 1 item and no more sections', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([listSection('ul', [listItem([marker('123')])])]); + expected = post([markupSection('p', [marker('ab123c')])]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = new Position(editor.post.sections.head, 'ab'.length); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head; + assert.positionIsEqual(renderedRange.head, + new Position(expectedSection, 'ab123'.length), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at end and paste is list with 1 item and no more sections', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([listSection('ul', [listItem([marker('123')])])]); + expected = post([markupSection('p', [marker('abc123')])]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head; + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at start and paste is list with 1 item and has more sections', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([ + listSection('ul', [listItem([marker('123')])]), + markupSection('p', [marker('def')]), + markupSection('p', [marker('ghi')]) + ]); + expected = post([ + markupSection('p', [marker('123')]), + markupSection('p', [marker('def')]), + markupSection('p', [marker('ghi')]), + markupSection('p', [marker('abc')]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.objectAt(2); + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at middle and paste is list with 1 item and has more sections', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([ + listSection('ul', [listItem([marker('123')])]), + markupSection('p', [marker('def')]), + markupSection('p', [marker('ghi')]) + ]); + expected = post([ + markupSection('p', [marker('ab123')]), + markupSection('p', [marker('def')]), + markupSection('p', [marker('ghi')]), + markupSection('p', [marker('c')]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = new Position(editor.post.sections.head, 'ab'.length); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.objectAt(2); + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at end and paste is list with 1 item and has more sections', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([ + listSection('ul', [listItem([marker('123')])]), + markupSection('p', [marker('def')]), + markupSection('p', [marker('ghi')]) + ]); + expected = post([ + markupSection('p', [marker('abc123')]), + markupSection('p', [marker('def')]), + markupSection('p', [marker('ghi')]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.tail; + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at start and paste is only list with > 1 item', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([ + listSection('ul', [ + listItem([marker('123')]), + listItem([marker('456')]) + ]) + ]); + expected = post([ + markupSection('p', [marker('123')]), + listSection('ul', [listItem([marker('456')])]), + markupSection('p', [marker('abc')]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.objectAt(1); + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at end and paste is only list with > 1 item', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([ + listSection('ul', [ + listItem([marker('123')]), + listItem([marker('456')]) + ]) + ]); + expected = post([ + markupSection('p', [marker('abc123')]), + listSection('ul', [listItem([marker('456')])]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = editor.post.sections.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.tail; + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in non-nested markerable at middle and paste is only list with > 1 item', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([ + listSection('ul', [ + listItem([marker('123')]), + listItem([marker('456')]) + ]) + ]); + expected = post([ + markupSection('p', [marker('ab123')]), + listSection('ul', [listItem([marker('456')])]), + markupSection('p', [marker('c')]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('abc')])]); + }); + + let position = new Position(editor.post.sections.head, 'ab'.length); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.objectAt(1); + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in nested markerable at start and paste is single non-nested markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([markupSection('p', [marker('123')])]); + expected = post([ + listSection('ul', [listItem([marker('123abc')])]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head.items.head; + assert.positionIsEqual(renderedRange.head, + new Position(expectedSection, '123'.length), + 'cursor at end of pasted content'); +}); + +test('in nested markerable at end and paste is single non-nested markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([markupSection('p', [marker('123')])]); + expected = post([ + listSection('ul', [listItem([marker('abc123')])]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let position = editor.post.sections.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head.items.head; + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted content'); +}); + +test('in nested markerable at middle and paste is single non-nested markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([markupSection('p', [marker('123')])]); + expected = post([ + listSection('ul', [listItem([marker('ab123c')])]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let position = new Position(editor.post.sections.head.items.head, 'ab'.length); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head.items.head; + assert.positionIsEqual(renderedRange.head, + new Position(expectedSection, 'ab123'.length), + 'cursor at end of pasted content'); +}); + +test('in nested markerable at start and paste is list with 1 item', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([listSection('ul', [listItem([marker('123')])])]); + expected = post([listSection('ul', [listItem([marker('123abc')])])]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head.items.head; + assert.positionIsEqual(renderedRange.head, + new Position(expectedSection, '123'.length), + 'cursor at end of pasted content'); +}); + +test('in nested markerable at end and paste is list with 1 item', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([listSection('ul', [listItem([marker('123')])])]); + expected = post([listSection('ul', [listItem([marker('abc123')])])]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let position = editor.post.sections.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head.items.head; + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted content'); +}); + +test('in nested markerable at middle and paste is list with 1 item', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([listSection('ul', [listItem([marker('123')])])]); + expected = post([listSection('ul', [listItem([marker('ab123c')])])]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let position = new Position(editor.post.sections.head.items.head, 'ab'.length); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head.items.head; + assert.positionIsEqual(renderedRange.head, + new Position(expectedSection, 'ab123'.length), + 'cursor at end of pasted content'); +}); + +test('in nested markerable at start and paste is list with > 1 item', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([listSection('ul', [listItem([marker('123')]), listItem([marker('456')])])]); + expected = post([listSection('ul', [ + listItem([marker('123')]), listItem([marker('456')]), listItem([marker('abc')])]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head.items.objectAt(1); + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in nested markerable at end and paste is list with > 1 item', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([listSection('ul', [listItem([marker('123')]), listItem([marker('456')])])]); + expected = post([listSection('ul', [listItem([marker('abc123')]), listItem([marker('456')])])]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let position = editor.post.sections.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + // FIXME is this the correct expected position? + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head.items.tail; + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in nested markerable at middle and paste is list with > 1 item', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([listSection('ul', [listItem([marker('123')]), listItem([marker('456')])])]); + expected = post([listSection('ul', [ + listItem([marker('ab123')]), listItem([marker('456')]), listItem([marker('c')])]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let position = new Position(editor.post.sections.head.items.head, 'ab'.length); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.head.items.objectAt(1); + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in nested markerable at start and paste is list with 1 item and more sections', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, markupSection, listSection, listItem, marker}) => { + toInsert = post([ + listSection('ul', [listItem([marker('123')])]), + markupSection('p', [marker('456')]) + ]); + expected = post([ + listSection('ul', [listItem([marker('123')])]), + markupSection('p', [marker('456')]), + listSection('ul', [listItem([marker('abc')])]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.objectAt(1); + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in blank nested markerable (1 item in list) and paste is non-markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, listSection, listItem}) => { + toInsert = post([ + cardSection('the-card', {foo: 'bar'}) + ]); + expected = post([ + listSection('ul', [listItem()]), + cardSection('the-card', {foo: 'bar'}) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem}) => { + return post([listSection('ul', [listItem()])]); + }); + + let position = editor.post.sections.head.headPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.renderTreeIsEqual(editor._renderTree, expected); + assert.postIsSimilar(editor.post, expected); + let expectedSection = editor.post.sections.tail; + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in nested markerable at end with multiple items and paste is non-markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, listSection, listItem, marker}) => { + toInsert = post([ + cardSection('the-card', {foo: 'bar'}) + ]); + expected = post([ + listSection('ul', [listItem([marker('123')])]), + cardSection('the-card', {foo: 'bar'}), + listSection('ul', [listItem([marker('abc')])]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('123')]), listItem([marker('abc')])])]); + }); + + let position = editor.post.sections.head.items.head.tailPosition(); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.postIsSimilar(editor.post, expected); + assert.renderTreeIsEqual(editor._renderTree, expected); + let expectedSection = editor.post.sections.objectAt(1); + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); + +test('in nested markerable at middle with multiple items and paste is non-markerable', (assert) => { + let toInsert, expected; + Helpers.postAbstract.build( + ({post, cardSection, listSection, listItem, marker}) => { + toInsert = post([ + cardSection('the-card', {foo: 'bar'}) + ]); + expected = post([ + listSection('ul', [listItem([marker('ab')])]), + cardSection('the-card', {foo: 'bar'}), + listSection('ul', [listItem([marker('c')]), listItem([marker('def')])]) + ]); + }); + + editor = buildEditorWithMobiledoc(({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')]), listItem([marker('def')])])]); + }); + + let position = new Position(editor.post.sections.head.items.head, + 'ab'.length); + postEditor = new PostEditor(editor); + postEditor.insertPost(position, toInsert); + postEditor.complete(); + + assert.postIsSimilar(editor.post, expected); + assert.renderTreeIsEqual(editor._renderTree, expected); + let expectedSection = editor.post.sections.objectAt(1); + assert.positionIsEqual(renderedRange.head, + expectedSection.tailPosition(), + 'cursor at end of pasted'); +}); diff --git a/tests/unit/models/list-section-test.js b/tests/unit/models/list-section-test.js new file mode 100644 index 000000000..f6abce45d --- /dev/null +++ b/tests/unit/models/list-section-test.js @@ -0,0 +1,24 @@ +import PostNodeBuilder from 'mobiledoc-kit/models/post-node-builder'; +import TestHelpers from '../../test-helpers'; + +const {module, test} = TestHelpers; + +let builder; +module('Unit: List Section', { + beforeEach() { + builder = new PostNodeBuilder(); + }, + afterEach() { + builder = null; + } +}); + +test('cloning a list section creates the same type of list section', (assert) => { + let item = builder.createListItem([builder.createMarker('abc')]); + let list = builder.createListSection('ol', [item]); + let cloned = list.clone(); + + assert.equal(list.tagName, cloned.tagName); + assert.equal(list.items.length, cloned.items.length); + assert.equal(list.items.head.text, cloned.items.head.text); +}); diff --git a/tests/unit/models/markup-section-test.js b/tests/unit/models/markup-section-test.js index aabbbb209..b493a3c4a 100644 --- a/tests/unit/models/markup-section-test.js +++ b/tests/unit/models/markup-section-test.js @@ -1,6 +1,7 @@ -const {module, test} = QUnit; - import PostNodeBuilder from 'mobiledoc-kit/models/post-node-builder'; +import TestHelpers from '../../test-helpers'; + +const {module, test} = TestHelpers; let builder; module('Unit: Markup Section', { @@ -148,3 +149,107 @@ test('instantiating with invalid tagName throws', (assert) => { builder.createMarkupSection('blah'); }, /Cannot set.*tagName.*blah/); }); + +test('markerBeforeOffset returns marker the ends at offset', (assert) => { + let marker = builder.createMarker; + let section = builder.createMarkupSection('p', [ + marker('a'), marker('bc'), marker('def') + ]); + + assert.ok(section.markerBeforeOffset(1) === section.markers.head); + assert.ok(section.markerBeforeOffset(3) === section.markers.objectAt(1)); + assert.ok(section.markerBeforeOffset(6) === section.markers.tail); +}); + +test('markerBeforeOffset throws if offset is not between markers', (assert) => { + let marker = builder.createMarker; + let section = builder.createMarkupSection('p', [ + marker('a'), marker('bc'), marker('def') + ]); + + assert.throws( + () => section.markerBeforeOffset(0), + /not between/ + ); + assert.throws( + () => section.markerBeforeOffset(2), + /not between/ + ); + assert.throws( + () => section.markerBeforeOffset(4), + /not between/ + ); + assert.throws( + () => section.markerBeforeOffset(5), + /not between/ + ); +}); + +test('markerBeforeOffset returns first marker if it is empty and offset is 0', (assert) => { + let marker = (text) => builder.createMarker(text); + let section = builder.createMarkupSection('p', [ + marker(''), marker('bc'), marker('def') + ]); + + assert.ok(section.markerBeforeOffset(0) === section.markers.head); +}); + +test('splitMarkerAtOffset inserts empty marker when offset is 0', (assert) => { + let section = builder.createMarkupSection('p', [builder.createMarker('abc')]); + + section.splitMarkerAtOffset(0); + + assert.equal(section.markers.length, 2); + assert.deepEqual(section.markers.map(m => m.value), ['', 'abc']); +}); + +test('splitMarkerAtOffset inserts empty marker if section is blank', (assert) => { + let section = builder.createMarkupSection('p'); + + section.splitMarkerAtOffset(0); + + assert.equal(section.markers.length, 1); + assert.deepEqual(section.markers.map(m => m.value), ['']); +}); + +test('splitMarkerAtOffset splits marker if offset is contained by marker', (assert) => { + let section = builder.createMarkupSection('p', [builder.createMarker('abc')]); + + section.splitMarkerAtOffset(1); + + assert.equal(section.markers.length, 2); + assert.deepEqual(section.markers.map(m => m.value), + ['a', 'bc']); +}); + +test('splitMarkerAtOffset is no-op when offset is at end of marker', (assert) => { + let section = builder.createMarkupSection('p', [builder.createMarker('abc')]); + + section.splitMarkerAtOffset(3); + + assert.equal(section.markers.length, 1); + assert.deepEqual(section.markers.map(m => m.value), ['abc']); +}); + +test('splitMarkerAtOffset does nothing if the is offset is at end', (assert) => { + let marker = (text) => builder.createMarker(text); + let section = builder.createMarkupSection('p', [marker('a'), marker('bc')]); + + section.splitMarkerAtOffset(3); + + assert.equal(section.markers.length, 2); + assert.deepEqual(section.markers.map(m => m.value), ['a', 'bc']); +}); + +test('splitMarkerAtOffset splits a marker deep in the middle', (assert) => { + let marker = (text) => builder.createMarker(text); + let section = builder.createMarkupSection('p', [ + marker('a'), marker('bc'), marker('def'), marker('ghi') + ]); + + section.splitMarkerAtOffset(5); + + assert.equal(section.markers.length, 5); + assert.deepEqual(section.markers.map(m => m.value), + ['a', 'bc', 'de', 'f', 'ghi']); +}); diff --git a/tests/unit/models/post-test.js b/tests/unit/models/post-test.js index f9e541dc4..f49df005f 100644 --- a/tests/unit/models/post-test.js +++ b/tests/unit/models/post-test.js @@ -456,3 +456,86 @@ test('#cloneRange copies card sections', (assert) => { assert.deepEqual(mobiledoc, expectedMobiledoc); }); + +test('#cloneRange when range starts and ends in a list item', (assert) => { + let buildPost = Helpers.postAbstract.build, + buildMobiledoc = Helpers.mobiledoc.build; + + let post = buildPost( + ({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('abc')])])]); + }); + + let range = Range.create(post.sections.head.items.head, 0, + post.sections.head.items.head, 'ab'.length); + + let mobiledoc = post.cloneRange(range); + let expected = buildMobiledoc( + ({post, listSection, listItem, marker}) => { + return post([listSection('ul', [listItem([marker('ab')])])]); + }); + + assert.deepEqual(mobiledoc, expected); +}); + +test('#cloneRange when range contains multiple list items', (assert) => { + let buildPost = Helpers.postAbstract.build, + buildMobiledoc = Helpers.mobiledoc.build; + + let post = buildPost( + ({post, listSection, listItem, marker}) => { + return post([listSection('ul', [ + listItem([marker('abc')]), + listItem([marker('def')]), + listItem([marker('ghi')]) + ])]); + }); + + let range = Range.create(post.sections.head.items.head, 'ab'.length, + post.sections.head.items.tail, 'gh'.length); + + let mobiledoc = post.cloneRange(range); + let expected = buildMobiledoc( + ({post, listSection, listItem, marker}) => { + return post([listSection('ul', [ + listItem([marker('c')]), + listItem([marker('def')]), + listItem([marker('gh')]) + ])]); + }); + + assert.deepEqual(mobiledoc, expected); +}); + +test('#cloneRange when range contains multiple list items and more sections', (assert) => { + let buildPost = Helpers.postAbstract.build, + buildMobiledoc = Helpers.mobiledoc.build; + + let post = buildPost( + ({post, listSection, listItem, markupSection, marker}) => { + return post([listSection('ul', [ + listItem([marker('abc')]), + listItem([marker('def')]), + listItem([marker('ghi')]) + ]), markupSection('p', [ + marker('123') + ])]); + }); + + let range = Range.create(post.sections.head.items.head, 'ab'.length, + post.sections.tail, '12'.length); + + let mobiledoc = post.cloneRange(range); + let expected = buildMobiledoc( + ({post, listSection, listItem, markupSection, marker}) => { + return post([listSection('ul', [ + listItem([marker('c')]), + listItem([marker('def')]), + listItem([marker('ghi')]) + ]), markupSection('p', [ + marker('12') + ])]); + }); + + assert.deepEqual(mobiledoc, expected); +});