diff --git a/src/js/editor/post.js b/src/js/editor/post.js index f87a9d7f2..e6a6aea24 100644 --- a/src/js/editor/post.js +++ b/src/js/editor/post.js @@ -2,13 +2,30 @@ import { DEFAULT_TAG_NAME as DEFAULT_MARKUP_SECTION_TAG_NAME } from '../models/markup-section'; import { isMarkerable } from '../models/_section'; -import { POST_TYPE, MARKUP_SECTION_TYPE, LIST_ITEM_TYPE } from '../models/types'; +import { POST_TYPE, MARKUP_SECTION_TYPE, LIST_ITEM_TYPE, LIST_SECTION_TYPE } from '../models/types'; import Position from '../utils/cursor/position'; import { isArrayEqual, forEach, filter, compact } from '../utils/array-utils'; import { DIRECTION } from '../utils/key'; import LifecycleCallbacksMixin from '../utils/lifecycle-callbacks'; import mixin from '../utils/mixin'; +function isJoinable(section1, section2) { + return isMarkerable(section1) && + isMarkerable(section2) && + section1.type === section2.type && + section1.tagName === section2.tagName; +} + +function endPosition(section) { + if (isMarkerable(section)) { + return new Position(section, section.length); + } else if (section.type === LIST_SECTION_TYPE) { + return endPosition(section.items.tail); + } else { + return new Position(section, 0); + } +} + function isMarkupSection(section) { return section.type === MARKUP_SECTION_TYPE; } @@ -624,29 +641,6 @@ class PostEditor { }); } - /** - * @method insertMarkers - * @param {Position} position to insert at - * @param {Array} markers to insert - * @return {Position} position at end of inserted markers - * @private - */ - insertMarkers(position, markers=[]) { - let { section, offset } = position; - this.splitSectionMarkerAtOffset(section, offset); - let {marker:prevMarker} = section.markerPositionAtOffset(offset); - let currentMarker = offset === 0 ? prevMarker : prevMarker.next; - - markers.forEach(marker => { - marker = marker.clone(); - section.markers.insertBefore(marker, currentMarker); - offset += marker.length; - this._markDirty(marker); - }); - - return new Position(section, offset); - } - /** * Toggle the given markup on the current selection. If anything in the current * selection has the markup, the markup will be removed from it. If nothing in the selection @@ -695,7 +689,7 @@ class PostEditor { m.clearMarkups(); this._markDirty(m); }); - section.setTagName(newTagName); + section.tagName = newTagName; this._markDirty(section); } @@ -762,25 +756,27 @@ class PostEditor { * @private */ insertPost(position, newPost) { - const post = this.editor.post; - const shouldSplitSection = newPost.sections.length > 1; - - if (!shouldSplitSection) { - const markers = newPost.sections.head.markers; - return this.insertMarkers(position, markers); + if (newPost.isBlank) { + return position; } + const post = this.editor.post; let [preSplit, postSplit] = this.splitSection(position); - const headSection = newPost.sections.head; - let lastInsertedSection = headSection; + let nextPosition = position.clone(); - newPost.sections.forEach(section => { - if (section === headSection) { - this._mergeSectionAtEnd(section, preSplit); + newPost.sections.forEach((section, index) => { + if (index === 0 && + isJoinable(preSplit, section)) { + + preSplit.join(section); + this._markDirty(preSplit); + + nextPosition = endPosition(preSplit); } else { section = section.clone(); - lastInsertedSection = section; this.insertSectionBefore(post.sections, section, postSplit); + + nextPosition = endPosition(section); } }); @@ -788,13 +784,19 @@ class PostEditor { this.removeSection(postSplit); } - return new Position(lastInsertedSection, lastInsertedSection.length); - } + if (isJoinable(preSplit, postSplit) && + preSplit.next === postSplit) { + + nextPosition = endPosition(preSplit); + + preSplit.join(postSplit); + this._markDirty(preSplit); + this.removeSection(postSplit); + } else if (preSplit.isBlank) { + this.removeSection(preSplit); + } - _mergeSectionAtEnd(sectionToMerge, existingSection) { - const markers = sectionToMerge.markers; - const position = new Position(existingSection, existingSection.length); - return this.insertMarkers(position, markers); + return nextPosition; } /** diff --git a/src/js/models/_section.js b/src/js/models/_section.js index 1ecb3329f..2f23ef8c0 100644 --- a/src/js/models/_section.js +++ b/src/js/models/_section.js @@ -34,13 +34,21 @@ export default class Section extends LinkedItem { } set tagName(val) { - this._tagName = normalizeTagName(val); + let normalizedTagName = normalizeTagName(val); + if (!this.isValidTagName(normalizedTagName)) { + throw new Error(`Cannot set section tagName to ${val}`); + } + this._tagName = normalizedTagName; } get tagName() { return this._tagName; } + isValidTagName(/* normalizedTagName */) { + throw new Error('`isValidTagName` must be implemented by subclass'); + } + get isBlank() { throw new Error('`isBlank` must be implemented by subclass'); } diff --git a/src/js/models/list-item.js b/src/js/models/list-item.js index 797e610ab..b752fcc74 100644 --- a/src/js/models/list-item.js +++ b/src/js/models/list-item.js @@ -3,6 +3,7 @@ import { LIST_ITEM_TYPE } from './types'; import { normalizeTagName } from 'content-kit-editor/utils/dom-utils'; +import { contains } from 'content-kit-editor/utils/array-utils'; export const VALID_LIST_ITEM_TAGNAMES = [ 'li' @@ -13,6 +14,10 @@ export default class ListItem extends Markerable { super(LIST_ITEM_TYPE, tagName, markers); } + isValidTagName(normalizedTagName) { + return contains(VALID_LIST_ITEM_TAGNAMES, normalizedTagName); + } + splitAtMarker(marker, offset=0) { // FIXME need to check if we are going to split into two list items // or a list item and a new markup section: diff --git a/src/js/models/list-section.js b/src/js/models/list-section.js index ebd86e76c..5c2463dd5 100644 --- a/src/js/models/list-section.js +++ b/src/js/models/list-section.js @@ -1,5 +1,8 @@ import LinkedList from '../utils/linked-list'; -import { forEach } from '../utils/array-utils'; +import { + forEach, + contains +} from '../utils/array-utils'; import { LIST_SECTION_TYPE } from './types'; import Section from './_section'; import { @@ -27,6 +30,10 @@ export default class ListSection extends Section { items.forEach(i => this.items.append(i)); } + isValidTagName(normalizedTagName) { + return contains(VALID_LIST_SECTION_TAGNAMES, normalizedTagName); + } + get isBlank() { return this.items.isEmpty; } diff --git a/src/js/models/markup-section.js b/src/js/models/markup-section.js index 2d24e4263..c9ff20022 100644 --- a/src/js/models/markup-section.js +++ b/src/js/models/markup-section.js @@ -1,5 +1,6 @@ import Markerable from './_markerable'; import { normalizeTagName } from '../utils/dom-utils'; +import { contains } from '../utils/array-utils'; import { MARKUP_SECTION_TYPE } from './types'; // valid values of `tagName` for a MarkupSection @@ -20,16 +21,8 @@ const MarkupSection = class MarkupSection extends Markerable { super(MARKUP_SECTION_TYPE, tagName, markers); } - setTagName(newTagName) { - newTagName = normalizeTagName(newTagName); - if (VALID_MARKUP_SECTION_TAGNAMES.indexOf(newTagName) === -1) { - throw new Error(`Cannot change section tagName to "${newTagName}`); - } - this.tagName = newTagName; - } - - resetTagName() { - this.tagName = DEFAULT_TAG_NAME; + isValidTagName(normalizedTagName) { + return contains(VALID_MARKUP_SECTION_TAGNAMES, normalizedTagName); } splitAtMarker(marker, offset=0) { diff --git a/src/js/utils/cursor.js b/src/js/utils/cursor.js index 53890c0ca..2c43d52a2 100644 --- a/src/js/utils/cursor.js +++ b/src/js/utils/cursor.js @@ -105,6 +105,10 @@ const Cursor = class Cursor { } moveToPosition(position) { + if (position._inCard) { + // FIXME add the ability to position the cursor on/in a card + return; + } this.selectRange(new Range(position, position)); } diff --git a/tests/unit/editor/post-test.js b/tests/unit/editor/post-test.js index 7e5e21955..154521da1 100644 --- a/tests/unit/editor/post-test.js +++ b/tests/unit/editor/post-test.js @@ -7,6 +7,10 @@ import { DIRECTION } from 'content-kit-editor/utils/key'; import PostNodeBuilder from 'content-kit-editor/models/post-node-builder'; import Range from 'content-kit-editor/utils/cursor/range'; import Position from 'content-kit-editor/utils/cursor/position'; +import { + LIST_SECTION_TYPE, + CARD_TYPE +} from 'content-kit-editor/models/types'; const { FORWARD } = DIRECTION; @@ -941,3 +945,156 @@ test('#insertPost multiple sections, insert at middle', (assert) => { 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'); +}); diff --git a/tests/unit/models/markup-section-test.js b/tests/unit/models/markup-section-test.js index a3e397b17..682ba7941 100644 --- a/tests/unit/models/markup-section-test.js +++ b/tests/unit/models/markup-section-test.js @@ -142,3 +142,9 @@ test('#markersFor clones a single marker with a tail offset', (assert) => { assert.equal(clones.length, 1); assert.equal(clones[0].value, ' '); }); + +test('instantiating with invalid tagName throws', (assert) => { + assert.throws(() => { + builder.createMarkupSection('blah'); + }, /Cannot set.*tagName.*blah/); +});