diff --git a/.jshintrc b/.jshintrc index c93d4911e..a5a5ed159 100644 --- a/.jshintrc +++ b/.jshintrc @@ -31,7 +31,7 @@ "undef" : true, // Require all non-global variables be declared before they are used. "unused" : true, // Warn when variables are created but not used. "trailing" : true, // Prohibit trailing whitespaces. - "es3" : false, // Prohibit trailing commas for old IE + "es3" : true, // Prohibit trailing commas for old IE "esnext" : true, // Allow ES.next specific features such as `const` and `let`. // == Relaxing Options ================================================ diff --git a/demo/demo.js b/demo/demo.js index bce09ff79..e45c5a88f 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -326,7 +326,7 @@ function attemptEditorReboot(editor, textPayload) { var MOBILEDOC_VERSION = "0.1"; var sampleMobiledocs = { - xsimpleMobiledoc: { + simpleMobiledoc: { version: MOBILEDOC_VERSION, sections: [ [], @@ -341,8 +341,7 @@ var sampleMobiledocs = { ] }, - //simpleMobiledocWithList: { - simpleMobiledoc: { + simpleMobiledocWithList: { version: MOBILEDOC_VERSION, sections: [ [], @@ -350,12 +349,10 @@ var sampleMobiledocs = { [1, "H2", [ [[], 0, "To do today:"] ]], - [1, "UL", [ - [ - [[], 0, "buy milk"], - [[], 0, "water cows"], - [[], 0, "world domination"] - ] + [3, 'ul', [ + [[[], 0, 'buy milk']], + [[[], 0, 'water plants']], + [[[], 0, 'world domination']] ]] ] ] diff --git a/demo/index.html b/demo/index.html index 9ffe90558..2970669ef 100644 --- a/demo/index.html +++ b/demo/index.html @@ -43,6 +43,7 @@

Try a Demo

diff --git a/src/js/commands/image.js b/src/js/commands/image.js index 97c1d2a5f..50d53a0d9 100644 --- a/src/js/commands/image.js +++ b/src/js/commands/image.js @@ -13,12 +13,13 @@ export default class ImageCommand extends Command { let beforeSection = headMarker.section; let afterSection = beforeSection.next; let section = this.editor.builder.createCardSection('image'); + const collection = beforeSection.parent.sections; this.editor.run((postEditor) => { if (beforeSection.isBlank) { postEditor.removeSection(beforeSection); } - postEditor.insertSectionBefore(section, afterSection); + postEditor.insertSectionBefore(collection, section, afterSection); }); } } diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index dab9577d4..8f60aa43b 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -35,7 +35,7 @@ import { } from '../utils/dom-utils'; import { forEach, - detect + filter } from '../utils/array-utils'; import { getData, setData } from '../utils/element-utils'; import mixin from '../utils/mixin'; @@ -446,78 +446,27 @@ class Editor { } reparse() { - // find added sections - let sectionsInDOM = []; - let newSections = []; - let previousSection; - - forEach(this.element.childNodes, (node) => { - // FIXME: this is kind of slow - let sectionRenderNode = detect(this._renderTree.node.childNodes, (renderNode) => { - return renderNode.element === node; - }); - if (!sectionRenderNode) { - let section = this._parser.parseSection(node); - newSections.push(section); - - // create a clean "already-rendered" node to represent the fact that - // this (new) section is already in DOM - sectionRenderNode = this._renderTree.buildRenderNode(section); - sectionRenderNode.element = node; - sectionRenderNode.markClean(); - - let previousSectionRenderNode = previousSection && previousSection.renderNode; - this.post.sections.insertAfter(section, previousSection); - this._renderTree.node.childNodes.insertAfter(sectionRenderNode, previousSectionRenderNode); - } - - // may cause duplicates to be included - let section = sectionRenderNode.postNode; - sectionsInDOM.push(section); - previousSection = section; - }); - - // remove deleted nodes - const deletedSections = []; - forEach(this.post.sections, (section) => { - if (!section.renderNode) { - throw new Error('All sections are expected to have a renderNode'); - } - - if (sectionsInDOM.indexOf(section) === -1) { - deletedSections.push(section); - } - }); - forEach(deletedSections, (s) => s.renderNode.scheduleForRemoval()); - - // reparse the new section(s) with the cursor - // to ensure that we catch any changed html that the browser might have - // added - const sectionsWithCursor = this.cursor.activeSections; - forEach(sectionsWithCursor, (section) => { - if (newSections.indexOf(section) === -1) { - this.reparseSection(section); - } - }); - - let { - headSection, - headSectionOffset - } = this.cursor.sectionOffsets; + let { headSection, headSectionOffset } = this.cursor.offsets; + if (headSectionOffset === 0) { + // FIXME if the offset is 0, the user is typing the first character + // in an empty section, so we need to move the cursor 1 letter forward + headSectionOffset = 1; + } - // The cursor will lose its textNode if we have reparsed (and thus will rerender, below) - // its section. Ensure the cursor is placed where it should be after render. - // - // New sections are presumed clean, and thus do not get rerendered and lose - // their cursor position. - let resetCursor = sectionsWithCursor.indexOf(headSection) !== -1; + this._reparseCurrentSection(); + this._removeDetachedSections(); this.rerender(); this.trigger('update'); - if (resetCursor) { - this.cursor.moveToSection(headSection, headSectionOffset); - } + this.cursor.moveToSection(headSection, headSectionOffset); + } + + _removeDetachedSections() { + forEach( + filter(this.post.sections, s => !s.renderNode.isAttached()), + s => s.renderNode.scheduleForRemoval() + ); } /* @@ -571,8 +520,9 @@ class Editor { section.renderNode.markDirty(); } - reparseSection(section) { - this._parser.reparseSection(section, this._renderTree); + _reparseCurrentSection() { + const {headSection:currentSection } = this.cursor.offsets; + this._parser.reparseSection(currentSection, this._renderTree); } serialize() { diff --git a/src/js/editor/post.js b/src/js/editor/post.js index c1940b5e5..e75a105d4 100644 --- a/src/js/editor/post.js +++ b/src/js/editor/post.js @@ -1,6 +1,8 @@ import { MARKUP_SECTION_TYPE } from '../models/markup-section'; +import { LIST_ITEM_TYPE } from '../models/list-item'; import { - filter + filter, + compact } from '../utils/array-utils'; import { DIRECTION } from '../utils/key'; @@ -9,9 +11,18 @@ function isMarkupSection(section) { return section.type === MARKUP_SECTION_TYPE; } +function isListItem(section) { + return section.type === LIST_ITEM_TYPE; +} + +function isBlankAndListItem(section) { + return isListItem(section) && section.isBlank; +} + class PostEditor { constructor(editor) { this.editor = editor; + this.builder = this.editor.builder; this._completionWorkQueue = []; this._didRerender = false; this._didUpdate = false; @@ -99,12 +110,11 @@ class PostEditor { } _coalesceMarkers(section) { - let {builder} = this.editor; filter(section.markers, m => m.isEmpty).forEach(m => { this.removeMarker(m); }); if (section.markers.isEmpty) { - section.markers.append(builder.createBlankMarker()); + section.markers.append(this.builder.createBlankMarker()); section.renderNode.markDirty(); } } @@ -191,6 +201,18 @@ class PostEditor { }; } + _convertListItemToMarkupSection(listItem) { + const listSection = listItem.parent; + + const newSections = listItem.splitIntoSections(); + const newMarkupSection = newSections[1]; + + this._replaceSection(listSection, compact(newSections)); + + const newCursorPosition = {marker: newMarkupSection.markers.head, offset: 0}; + return newCursorPosition; + } + /** * delete 1 character in the BACKWARD direction from the given position * @method _deleteBackwardFrom @@ -206,10 +228,15 @@ class PostEditor { if (prevMarker) { return this._deleteBackwardFrom({marker: prevMarker, offset: prevMarker.length}); } else { - const prevSection = marker.section.prev; - - if (prevSection) { - if (isMarkupSection(prevSection)) { + const section = marker.section; + + if (isListItem(section)) { + const newCursorPos = this._convertListItemToMarkupSection(section); + nextCursorMarker = newCursorPos.marker; + nextCursorOffset = newCursorPos.offset; + } else { + const prevSection = section.prev; + if (prevSection && isMarkupSection(prevSection)) { nextCursorMarker = prevSection.markers.tail; nextCursorOffset = nextCursorMarker.length; @@ -225,7 +252,6 @@ class PostEditor { nextCursorOffset = 0; } } - // ELSE: FIXME: card section -- what should deleting into it do? } } @@ -258,7 +284,7 @@ class PostEditor { } /** - * Split makers at two positions, once at the head, and if necessary once + * Split markers at two positions, once at the head, and if necessary once * at the tail. This method is designed to accept `editor.cursor.offsets` * as an argument. * @@ -375,17 +401,40 @@ class PostEditor { const [beforeSection, afterSection] = section.splitAtMarker(headMarker, headMarkerOffset); - this._replaceSection(section, [beforeSection, afterSection]); + const newSections = [beforeSection, afterSection]; + let replacementSections = [beforeSection, afterSection]; + + if (isBlankAndListItem(beforeSection) && isBlankAndListItem(section)) { + const isLastItemInList = section === section.parent.sections.tail; + + if (isLastItemInList) { + // when hitting enter in a final empty list item, do not insert a new + // empty item + replacementSections.shift(); + } + } + + this._replaceSection(section, replacementSections); this.scheduleRerender(); this.scheduleDidUpdate(); - return [beforeSection, afterSection]; + // FIXME we must return 2 sections because other code expects this to always return 2 + return newSections; } _replaceSection(section, newSections) { let nextSection = section.next; - newSections.forEach(s => this.insertSectionBefore(s, nextSection)); + let collection = section.parent.sections; + + let nextNewSection = newSections[0]; + if (isMarkupSection(nextNewSection) && isListItem(section)) { + // put the new section after the ListSection (section.parent) instead of after the ListItem + collection = section.parent.parent.sections; + nextSection = section.parent.next; + } + + newSections.forEach(s => this.insertSectionBefore(collection, s, nextSection)); this.removeSection(section); } @@ -472,18 +521,20 @@ class PostEditor { * let markerRange = editor.cursor.offsets; * let sectionWithCursor = markerRange.headMarker.section; * let section = editor.builder.createCardSection('my-image'); + * let collection = sectionWithCursor.parent.sections; * editor.run((postEditor) => { - * postEditor.insertSectionBefore(section, sectionWithCursor); + * postEditor.insertSectionBefore(collection, section, sectionWithCursor); * }); * * @method insertSectionBefore + * @param {LinkedList} collection The list of sections to insert into * @param {Object} section The new section * @param {Object} beforeSection The section "before" is relative to * @public */ - insertSectionBefore(section, beforeSection) { - this.editor.post.sections.insertBefore(section, beforeSection); - this.editor.post.renderNode.markDirty(); + insertSectionBefore(collection, section, beforeSection) { + collection.insertBefore(section, beforeSection); + section.parent.renderNode.markDirty(); this.scheduleRerender(); this.scheduleDidUpdate(); @@ -500,13 +551,13 @@ class PostEditor { * postEditor.removeSection(sectionWithCursor); * }); * - * @method insertSectionBefore + * @method removeSection * @param {Object} section The section to remove * @public */ removeSection(section) { section.renderNode.scheduleForRemoval(); - section.post.sections.remove(section); + section.parent.sections.remove(section); this.scheduleRerender(); this.scheduleDidUpdate(); diff --git a/src/js/models/list-item.js b/src/js/models/list-item.js new file mode 100644 index 000000000..42182c73c --- /dev/null +++ b/src/js/models/list-item.js @@ -0,0 +1,36 @@ +import Section from './markup-section'; + +export const LIST_ITEM_TYPE = 'list-item'; + +export default class ListItem extends Section { + constructor(tagName, markers=[]) { + super(tagName, markers); + this.type = LIST_ITEM_TYPE; + } + + 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: + const isLastItem = !this.next; + const createNewSection = + (marker.isEmpty && offset === 0) && isLastItem; + + let [beforeSection, afterSection] = [ + this.builder.createListItem(), + createNewSection ? this.builder.createMarkupSection('p') : this.builder.createListItem() + ]; + + return this._redistributeMarkers(beforeSection, afterSection, marker, offset); + } + + splitIntoSections() { + return this.parent.splitAtListItem(this); + } + + clone() { + const item = this.builder.createListItem(); + this.markers.forEach(m => item.markers.append(m.clone())); + return item; + } +} + diff --git a/src/js/models/list-section.js b/src/js/models/list-section.js new file mode 100644 index 000000000..ea8e5a39c --- /dev/null +++ b/src/js/models/list-section.js @@ -0,0 +1,60 @@ +import Section from './markup-section'; +import LinkedList from '../utils/linked-list'; + +export const LIST_SECTION_TYPE = 'list-section'; + +export default class ListSection extends Section { + constructor(tagName, items=[]) { + super(tagName); + this.type = LIST_SECTION_TYPE; + + // remove the inherited `markers` because they do nothing on a ListSection but confuse + this.markers = undefined; + + this.items = new LinkedList({ + adoptItem: i => i.section = i.parent = this, + freeItem: i => i.section = i.parent = null + }); + this.sections = this.items; + + items.forEach(i => this.items.append(i)); + } + + // returns [prevListSection, newMarkupSection, nextListSection] + // prevListSection and nextListSection may be undefined + splitAtListItem(listItem) { + if (listItem.parent !== this) { + throw new Error('Cannot split list section at item that is not a child'); + } + const prevItem = listItem.prev, + nextItem = listItem.next; + const listSection = this; + + let prevListSection, nextListSection, newSection; + + newSection = this.builder.createMarkupSection('p'); + listItem.markers.forEach(m => newSection.markers.append(m.clone())); + + // If there were previous list items, add them to a new list section `prevListSection` + if (prevItem) { + prevListSection = this.builder.createListSection(this.tagName); + let currentItem = listSection.items.head; + while (currentItem !== listItem) { + prevListSection.items.append(currentItem.clone()); + currentItem = currentItem.next; + } + } + + // if there is a next item, add it and all after it to the `nextListSection` + if (nextItem) { + nextListSection = this.builder.createListSection(this.tagName); + let currentItem = nextItem; + while (currentItem) { + nextListSection.items.append(currentItem.clone()); + currentItem = currentItem.next; + } + } + + return [prevListSection, newSection, nextListSection]; + } +} diff --git a/src/js/models/markup-section.js b/src/js/models/markup-section.js index 05cdab4bb..e9c86173f 100644 --- a/src/js/models/markup-section.js +++ b/src/js/models/markup-section.js @@ -19,12 +19,11 @@ export default class Section extends LinkedItem { constructor(tagName, markers=[]) { super(); this.markers = new LinkedList({ - adoptItem: m => m.section = this, - freeItem: m => m.section = null + adoptItem: m => m.section = m.parent = this, + freeItem: m => m.section = m.parent = null }); this.tagName = tagName || DEFAULT_TAG_NAME; this.type = MARKUP_SECTION_TYPE; - this.element = null; markers.forEach(m => this.markers.append(m)); } @@ -76,12 +75,7 @@ export default class Section extends LinkedItem { return newMarkers; } - splitAtMarker(marker, offset=0) { - let [beforeSection, afterSection] = [ - this.builder.createMarkupSection(this.tagName, []), - this.builder.createMarkupSection(this.tagName, []) - ]; - + _redistributeMarkers(beforeSection, afterSection, marker, offset=0) { let currentSection = beforeSection; forEach(this.markers, m => { if (m === marker) { @@ -100,6 +94,15 @@ export default class Section extends LinkedItem { return [beforeSection, afterSection]; } + splitAtMarker(marker, offset=0) { + let [beforeSection, afterSection] = [ + this.builder.createMarkupSection(this.tagName, []), + this.builder.createMarkupSection(this.tagName, []) + ]; + + return this._redistributeMarkers(beforeSection, afterSection, marker, offset); + } + /** * Remove extranous empty markers, adding one at the end if there * are no longer any markers diff --git a/src/js/models/post-node-builder.js b/src/js/models/post-node-builder.js index c53fb0b88..087b690e4 100644 --- a/src/js/models/post-node-builder.js +++ b/src/js/models/post-node-builder.js @@ -1,5 +1,7 @@ import Post from '../models/post'; import MarkupSection from '../models/markup-section'; +import ListSection from '../models/list-section'; +import ListItem from '../models/list-item'; import ImageSection from '../models/image'; import Marker from '../models/marker'; import Markup from '../models/markup'; @@ -37,10 +39,24 @@ export default class PostNodeBuilder { createBlankMarkupSection(tagName) { tagName = normalizeTagName(tagName); - let blankMarker = this.createBlankMarker(); + const blankMarker = this.createBlankMarker(); return this.createMarkupSection(tagName, [ blankMarker ]); } + createListSection(tagName, items=[]) { + tagName = normalizeTagName(tagName); + const section = new ListSection(tagName, items); + section.builder = this; + return section; + } + + createListItem(markers=[]) { + const tagName = normalizeTagName('li'); + const item = new ListItem(tagName, markers); + item.builder = this; + return item; + } + createImageSection(url) { let section = new ImageSection(); if (url) { diff --git a/src/js/models/post.js b/src/js/models/post.js index 6caf6d89f..fae6273ba 100644 --- a/src/js/models/post.js +++ b/src/js/models/post.js @@ -5,8 +5,8 @@ export default class Post { constructor() { this.type = POST_TYPE; this.sections = new LinkedList({ - adoptItem: s => s.post = this, - freeItem: s => s.post = null + adoptItem: s => s.post = s.parent = this, + freeItem: s => s.post = s.parent = null }); } diff --git a/src/js/models/render-node.js b/src/js/models/render-node.js index 1047e4764..1b92cd381 100644 --- a/src/js/models/render-node.js +++ b/src/js/models/render-node.js @@ -1,5 +1,6 @@ -import LinkedItem from "content-kit-editor/utils/linked-item"; -import LinkedList from "content-kit-editor/utils/linked-list"; +import LinkedItem from 'content-kit-editor/utils/linked-item'; +import LinkedList from 'content-kit-editor/utils/linked-list'; +import { containsNode } from 'content-kit-editor/utils/dom-utils'; export default class RenderNode extends LinkedItem { constructor(postNode) { @@ -11,6 +12,13 @@ export default class RenderNode extends LinkedItem { this._childNodes = null; this.element = null; } + isAttached() { + const rootElement = this.renderTree.node.element; + if (!this.element) { + throw new Error('Cannot check if a renderNode is attached without an element.'); + } + return containsNode(rootElement, this.element); + } get childNodes() { if (!this._childNodes) { this._childNodes = new LinkedList({ diff --git a/src/js/parsers/mobiledoc.js b/src/js/parsers/mobiledoc.js index 21ad49d76..f838b5147 100644 --- a/src/js/parsers/mobiledoc.js +++ b/src/js/parsers/mobiledoc.js @@ -1,5 +1,9 @@ -const CARD_SECTION_TYPE = 10; -const IMAGE_SECTION_TYPE = 2; +import { + MOBILEDOC_MARKUP_SECTION_TYPE, + MOBILEDOC_IMAGE_SECTION_TYPE, + MOBILEDOC_LIST_SECTION_TYPE, + MOBILEDOC_CARD_SECTION_TYPE +} from '../renderers/mobiledoc'; /* * input mobiledoc: [ markers, elements ] @@ -46,15 +50,18 @@ export default class MobiledocParser { parseSection(section, post) { let [type] = section; switch(type) { - case 1: // markup section + case MOBILEDOC_MARKUP_SECTION_TYPE: this.parseMarkupSection(section, post); break; - case IMAGE_SECTION_TYPE: + case MOBILEDOC_IMAGE_SECTION_TYPE: this.parseImageSection(section, post); break; - case CARD_SECTION_TYPE: + case MOBILEDOC_CARD_SECTION_TYPE: this.parseCardSection(section, post); break; + case MOBILEDOC_LIST_SECTION_TYPE: + this.parseListSection(section, post); + break; default: throw new Error(`Unexpected section type ${type}`); } @@ -80,16 +87,32 @@ export default class MobiledocParser { } } - parseMarkers(markers, section) { - markers.forEach((marker) => this.parseMarker(marker, section)); + parseListSection([type, tagName, items], post) { + const section = this.builder.createListSection(tagName); + post.sections.append(section); + this.parseListItems(items, section); + } + + parseListItems(items, section) { + items.forEach(i => this.parseListItem(i, section)); + } + + parseListItem(markers, section) { + const item = this.builder.createListItem(); + this.parseMarkers(markers, item); + section.items.append(item); + } + + parseMarkers(markers, parent) { + markers.forEach(m => this.parseMarker(m, parent)); } - parseMarker([markerTypeIndexes, closeCount, value], section) { + parseMarker([markerTypeIndexes, closeCount, value], parent) { markerTypeIndexes.forEach(index => { this.markups.push(this.markerTypes[index]); }); const marker = this.builder.createMarker(value, this.markups.slice()); - section.markers.append(marker); + parent.markers.append(marker); this.markups = this.markups.slice(0, this.markups.length-closeCount); } } diff --git a/src/js/parsers/post.js b/src/js/parsers/post.js index 33178b9aa..bacf7ca6f 100644 --- a/src/js/parsers/post.js +++ b/src/js/parsers/post.js @@ -1,16 +1,11 @@ import { MARKUP_SECTION_TYPE } from '../models/markup-section'; +import { LIST_SECTION_TYPE } from '../models/list-section'; +import { LIST_ITEM_TYPE } from '../models/list-item'; import SectionParser from 'content-kit-editor/parsers/section'; import { forEach } from 'content-kit-editor/utils/array-utils'; import { getAttributesArray, walkTextNodes } from '../utils/dom-utils'; -import { UNPRINTABLE_CHARACTER } from 'content-kit-editor/renderers/editor-dom'; import Markup from 'content-kit-editor/models/markup'; -const sanitizeTextRegex = new RegExp(UNPRINTABLE_CHARACTER, 'g'); - -function sanitizeText(text) { - return text.replace(sanitizeTextRegex, ''); -} - export default class PostParser { constructor(builder) { this.builder = builder; @@ -34,47 +29,67 @@ export default class PostParser { return this.sectionParser.parse(element); } + // walk up from the textNode until the rootNode, converting each + // parentNode into a markup + collectMarkups(textNode, rootNode) { + let markups = []; + let currentNode = textNode.parentNode; + while (currentNode && currentNode !== rootNode) { + let markup = this.markupFromNode(currentNode); + if (markup) { + markups.push(markup); + } + + currentNode = currentNode.parentNode; + } + return markups; + } + + // Turn an element node into a markup + markupFromNode(node) { + if (Markup.isValidElement(node)) { + let tagName = node.tagName; + let attributes = getAttributesArray(node); + + return this.builder.createMarkup(tagName, attributes); + } + } + // FIXME should move to the section parser? // FIXME the `collectMarkups` logic could simplify the section parser? reparseSection(section, renderTree) { - if (section.type !== MARKUP_SECTION_TYPE) { - // can only reparse markup sections - return; + switch (section.type) { + case LIST_SECTION_TYPE: + return this.reparseListSection(section, renderTree); + case LIST_ITEM_TYPE: + return this.reparseListItem(section, renderTree); + case MARKUP_SECTION_TYPE: + return this.reparseMarkupSection(section, renderTree); + default: + return; // can only parse the above types } - const sectionElement = section.renderNode.element; + } - // Turn an element node into a markup - const markupFromNode = (node) => { - if (Markup.isValidElement(node)) { - let tagName = node.tagName; - let attributes = getAttributesArray(node); + reparseMarkupSection(section, renderTree) { + return this._reparseSectionContainingMarkers(section, renderTree); + } - return this.builder.createMarkup(tagName, attributes); - } - }; - - // walk up from the textNode until the rootNode, converting each - // parentNode into a markup - const collectMarkups = (textNode, rootNode) =>{ - let markups = []; - let currentNode = textNode.parentNode; - while (currentNode && currentNode !== rootNode) { - let markup = markupFromNode(currentNode); - if (markup) { - markups.push(markup); - } + reparseListItem(listItem, renderTree) { + return this._reparseSectionContainingMarkers(listItem, renderTree); + } - currentNode = currentNode.parentNode; - } - return markups; - }; + reparseListSection(listSection, renderTree) { + listSection.items.forEach(li => this.reparseListItem(li, renderTree)); + } + _reparseSectionContainingMarkers(section, renderTree) { + const element = section.renderNode.element; let seenRenderNodes = []; let previousMarker; - walkTextNodes(sectionElement, (textNode) => { - const text = sanitizeText(textNode.textContent); - let markups = collectMarkups(textNode, sectionElement); + walkTextNodes(element, (textNode) => { + const text = textNode.textContent; + let markups = this.collectMarkups(textNode, element); let marker; @@ -90,24 +105,14 @@ export default class PostParser { } else { marker = this.builder.createMarker(text, markups); - // create a cleaned render node to account for the fact that this - // render node comes from already-displayed DOM - // FIXME this should be cleaner renderNode = renderTree.buildRenderNode(marker); renderNode.element = textNode; renderNode.markClean(); - if (previousMarker) { - // insert this marker after the previous one - section.markers.insertAfter(marker, previousMarker); - section.renderNode.childNodes.insertAfter(renderNode, previousMarker.renderNode); - } else { - // insert marker at the beginning of the section - section.markers.prepend(marker); - section.renderNode.childNodes.insertAfter(renderNode, null); - } + let previousRenderNode = previousMarker && previousMarker.renderNode; + section.markers.insertAfter(marker, previousMarker); + section.renderNode.childNodes.insertAfter(renderNode, previousRenderNode); - // find the nextMarkerElement, set it on the render node let parentNodeCount = marker.closedMarkups.length; let nextMarkerElement = textNode.parentNode; while (parentNodeCount--) { @@ -120,18 +125,12 @@ export default class PostParser { previousMarker = marker; }); - // remove any nodes that were not marked as seen - section.renderNode.childNodes.forEach(childRenderNode => { - if (seenRenderNodes.indexOf(childRenderNode) === -1) { - childRenderNode.scheduleForRemoval(); + let renderNode = section.renderNode.childNodes.head; + while (renderNode) { + if (seenRenderNodes.indexOf(renderNode) === -1) { + renderNode.scheduleForRemoval(); } - }); - - /** FIXME that we are reparsing and there are no markers should never - * happen. We manage the delete key on our own. */ - if (section.markers.isEmpty) { - let marker = this.builder.createBlankMarker(); - section.markers.append(marker); + renderNode = renderNode.next; } } } diff --git a/src/js/renderers/editor-dom.js b/src/js/renderers/editor-dom.js index 88ad9548b..5b54b6707 100644 --- a/src/js/renderers/editor-dom.js +++ b/src/js/renderers/editor-dom.js @@ -3,10 +3,11 @@ import CardNode from "content-kit-editor/models/card-node"; import { detect } from 'content-kit-editor/utils/array-utils'; import { POST_TYPE } from "../models/post"; import { MARKUP_SECTION_TYPE } from "../models/markup-section"; +import { LIST_SECTION_TYPE } from "../models/list-section"; +import { LIST_ITEM_TYPE } from "../models/list-item"; import { MARKER_TYPE } from "../models/marker"; import { IMAGE_SECTION_TYPE } from "../models/image"; import { CARD_TYPE } from "../models/card"; -import { clearChildNodes } from '../utils/dom-utils'; import { startsWith, endsWith } from '../utils/string-utils'; export const UNPRINTABLE_CHARACTER = "\u200C"; @@ -28,7 +29,7 @@ function createElementFromMarkup(doc, markup) { function penultimateParentOf(element, parentElement) { while (parentElement && element.parentNode !== parentElement && - element.parentElement !== document.body // ensure the while loop stops + element.parentNode !== document.body // ensure the while loop stops ) { element = element.parentNode; } @@ -36,9 +37,15 @@ function penultimateParentOf(element, parentElement) { } function renderMarkupSection(section) { - var element = document.createElement(section.tagName); - section.element = element; - return element; + return document.createElement(section.tagName); +} + +function renderListSection(section) { + return document.createElement(section.tagName); +} + +function renderListItem() { + return document.createElement('li'); } function getNextMarkerElement(renderNode) { @@ -104,6 +111,37 @@ function renderMarker(marker, element, previousRenderNode) { return textNode; } +function attachRenderNodeElementToDOM(renderNode, element, originalElement) { + const hasRendered = !!originalElement; + + if (hasRendered) { + let parentElement = renderNode.parent.element; + parentElement.replaceChild(element, originalElement); + } else { + let parentElement, nextSiblingElement; + if (renderNode.prev) { + let previousElement = renderNode.prev.element; + parentElement = previousElement.parentNode; + nextSiblingElement = previousElement.nextSibling; + } else { + parentElement = renderNode.parent.element; + nextSiblingElement = parentElement.firstChild; + } + parentElement.insertBefore(element, nextSiblingElement); + } +} + +function removeRenderNodeSectionFromParent(renderNode, section) { + const parent = renderNode.parent.postNode; + parent.sections.remove(section); +} + +function removeRenderNodeElementFromParent(renderNode) { + if (renderNode.element.parentNode) { + renderNode.element.parentNode.removeChild(renderNode.element); + } +} + class Visitor { constructor(editor, cards, unknownCardHandler, options) { this.editor = editor; @@ -121,34 +159,39 @@ class Visitor { } [MARKUP_SECTION_TYPE](renderNode, section, visit) { - let originalElement = renderNode.element; - const hasRendered = !!originalElement; + const originalElement = renderNode.element; // Always rerender the section -- its tag name or attributes may have changed. // TODO make this smarter, only rerendering and replacing the element when necessary let element = renderMarkupSection(section); renderNode.element = element; - if (!hasRendered) { - let element = renderNode.element; + attachRenderNodeElementToDOM(renderNode, element, originalElement); - if (renderNode.prev) { - let previousElement = renderNode.prev.element; - let parentNode = previousElement.parentNode; - parentNode.insertBefore(element, previousElement.nextSibling); - } else { - let parentElement = renderNode.parent.element; - parentElement.insertBefore(element, parentElement.firstChild); - } - } else { - renderNode.parent.element.replaceChild(element, originalElement); - } + const visitAll = true; + visit(renderNode, section.markers, visitAll); + } - // remove all elements so that we can rerender - clearChildNodes(renderNode.element); + [LIST_SECTION_TYPE](renderNode, section, visit) { + const originalElement = renderNode.element; + const element = renderListSection(section); + renderNode.element = element; + + attachRenderNodeElementToDOM(renderNode, element, originalElement); const visitAll = true; - visit(renderNode, section.markers, visitAll); + visit(renderNode, section.items, visitAll); + } + + [LIST_ITEM_TYPE](renderNode, item, visit) { + // FIXME do we need to do anything special for rerenders? + const element = renderListItem(); + renderNode.element = element; + + attachRenderNodeElementToDOM(renderNode, element, null); + + const visitAll = true; + visit(renderNode, item.markers, visitAll); } [MARKER_TYPE](renderNode, marker) { @@ -227,14 +270,20 @@ let destroyHooks = { [POST_TYPE](/*renderNode, post*/) { throw new Error('post destruction is not supported by the renderer'); }, + [MARKUP_SECTION_TYPE](renderNode, section) { - let post = renderNode.parent.postNode; - post.sections.remove(section); - // Some formatting commands remove the element from the DOM during - // formatting. Do not error if this is the case. - if (renderNode.element.parentNode) { - renderNode.element.parentNode.removeChild(renderNode.element); - } + removeRenderNodeSectionFromParent(renderNode, section); + removeRenderNodeElementFromParent(renderNode); + }, + + [LIST_SECTION_TYPE](renderNode, section) { + removeRenderNodeSectionFromParent(renderNode, section); + removeRenderNodeElementFromParent(renderNode); + }, + + [LIST_ITEM_TYPE](renderNode, li) { + removeRenderNodeSectionFromParent(renderNode, li); + removeRenderNodeElementFromParent(renderNode); }, [MARKER_TYPE](renderNode, marker) { @@ -258,28 +307,31 @@ let destroyHooks = { }, [IMAGE_SECTION_TYPE](renderNode, section) { - let post = renderNode.parent.postNode; - post.sections.remove(section); - renderNode.element.parentNode.removeChild(renderNode.element); + removeRenderNodeSectionFromParent(renderNode, section); + removeRenderNodeElementFromParent(renderNode); }, [CARD_TYPE](renderNode, section) { if (renderNode.cardNode) { renderNode.cardNode.teardown(); } - let post = renderNode.parent.postNode; - post.sections.remove(section); - renderNode.element.parentNode.removeChild(renderNode.element); + removeRenderNodeSectionFromParent(renderNode, section); + removeRenderNodeElementFromParent(renderNode); } }; // removes children from parentNode that are scheduled for removal function removeChildren(parentNode) { let child = parentNode.childNodes.head; + let nextChild, method; while (child) { - let nextChild = child.next; + nextChild = child.next; if (child.isRemoved) { - destroyHooks[child.postNode.type](child, child.postNode); + method = child.postNode.type; + if (!destroyHooks[method]) { + throw new Error(`editor-dom cannot destroy "${method}"`); + } + destroyHooks[method](child, child.postNode); parentNode.childNodes.remove(child); } child = nextChild; @@ -319,9 +371,17 @@ export default class Renderer { render(renderTree) { let node = renderTree.node; + let method, postNode; + while (node) { removeChildren(node); - this.visitor[node.postNode.type](node, node.postNode, (...args) => this.visit(renderTree, ...args)); + postNode = node.postNode; + + method = postNode.type; + if (!this.visitor[method]) { + throw new Error(`EditorDom visitor cannot handle type ${method}`); + } + this.visitor[node.postNode.type](node, postNode, (...args) => this.visit(renderTree, ...args)); node.markClean(); node = this.nodes.shift(); } diff --git a/src/js/renderers/mobiledoc.js b/src/js/renderers/mobiledoc.js index dab59494c..7b6e3bb32 100644 --- a/src/js/renderers/mobiledoc.js +++ b/src/js/renderers/mobiledoc.js @@ -1,12 +1,19 @@ import {visit, visitArray, compile} from "../utils/compiler"; import { POST_TYPE } from "../models/post"; import { MARKUP_SECTION_TYPE } from "../models/markup-section"; +import { LIST_SECTION_TYPE } from "../models/list-section"; +import { LIST_ITEM_TYPE } from "../models/list-item"; import { IMAGE_SECTION_TYPE } from "../models/image"; import { MARKER_TYPE } from "../models/marker"; import { MARKUP_TYPE } from "../models/markup"; import { CARD_TYPE } from "../models/card"; -export const MOBILEDOC_VERSION = '0.1'; +export const MOBILEDOC_VERSION = '0.2.0'; + +export const MOBILEDOC_MARKUP_SECTION_TYPE = 1; +export const MOBILEDOC_IMAGE_SECTION_TYPE = 2; +export const MOBILEDOC_LIST_SECTION_TYPE = 3; +export const MOBILEDOC_CARD_SECTION_TYPE = 10; let visitor = { [POST_TYPE](node, opcodes) { @@ -17,6 +24,14 @@ let visitor = { opcodes.push(['openMarkupSection', node.tagName]); visitArray(visitor, node.markers, opcodes); }, + [LIST_SECTION_TYPE](node, opcodes) { + opcodes.push(['openListSection', node.tagName]); + visitArray(visitor, node.items, opcodes); + }, + [LIST_ITEM_TYPE](node, opcodes) { + opcodes.push(['openListItem']); + visitArray(visitor, node.markers, opcodes); + }, [IMAGE_SECTION_TYPE](node, opcodes) { opcodes.push(['openImageSection', node.src]); }, @@ -43,13 +58,21 @@ let postOpcodeCompiler = { }, openMarkupSection(tagName) { this.markers = []; - this.sections.push([1, tagName, this.markers]); + this.sections.push([MOBILEDOC_MARKUP_SECTION_TYPE, tagName, this.markers]); + }, + openListSection(tagName) { + this.items = []; + this.sections.push([MOBILEDOC_LIST_SECTION_TYPE, tagName, this.items]); + }, + openListItem() { + this.markers = []; + this.items.push(this.markers); }, openImageSection(url) { - this.sections.push([2, url]); + this.sections.push([MOBILEDOC_IMAGE_SECTION_TYPE, url]); }, openCardSection(name, payload) { - this.sections.push([10, name, payload]); + this.sections.push([MOBILEDOC_CARD_SECTION_TYPE, name, payload]); }, openPost() { this.markerTypes = []; diff --git a/src/js/utils/array-utils.js b/src/js/utils/array-utils.js index 055593100..a4305631c 100644 --- a/src/js/utils/array-utils.js +++ b/src/js/utils/array-utils.js @@ -56,10 +56,16 @@ function commonItemLength(listA, listB) { return offset; } +// return new array without falsy items like ruby's `compact` +function compact(enumerable) { + return filter(enumerable, i => !!i); +} + export { detect, forEach, any, filter, - commonItemLength + commonItemLength, + compact }; diff --git a/src/js/utils/compiler.js b/src/js/utils/compiler.js index dcdc13779..9e24af59a 100644 --- a/src/js/utils/compiler.js +++ b/src/js/utils/compiler.js @@ -1,5 +1,9 @@ export function visit(visitor, node, opcodes) { - visitor[node.type](node, opcodes); + const method = node.type; + if (!visitor[method]) { + throw new Error(`Cannot visit unknown type ${method}`); + } + visitor[method](node, opcodes); } export function compile(compiler, opcodes) { diff --git a/src/js/utils/cursor.js b/src/js/utils/cursor.js index 9e7bdf8cf..233b5f793 100644 --- a/src/js/utils/cursor.js +++ b/src/js/utils/cursor.js @@ -3,30 +3,10 @@ import { isSelectionInElement, clearSelection } from '../utils/selection-utils'; -import { - detectParentNode, - isTextNode, - walkDOM -} from '../utils/dom-utils'; -import Position from "./cursor/position"; -import Range from "./cursor/range"; - -function findOffsetInParent(parentElement, targetElement, targetOffset) { - let offset = 0; - let found = false; - // FIXME: would be nice to exit this walk early after we find the end node - walkDOM(parentElement, (childElement) => { - if (found) { return; } - found = childElement === targetElement; - - if (found) { - offset += targetOffset; - } else if (isTextNode(childElement)) { - offset += childElement.textContent.length; - } - }); - return offset; -} + +import { detectParentNode } from '../utils/dom-utils'; +import Position from './cursor/position'; +import Range from './cursor/range'; function findSectionContaining(sections, childNode) { const { result: section } = detectParentNode(childNode, node => { @@ -80,19 +60,7 @@ export default class Cursor { } get sectionOffsets() { - const { sections } = this.post; - const selection = this.selection; - const { rangeCount } = selection; - const range = rangeCount > 0 && selection.getRangeAt(0); - - if (!range) { - return {}; - } - - let {leftNode:headNode, leftOffset:headOffset} = comparePosition(selection); - let headSection = findSectionContaining(sections, headNode); - let headSectionOffset = findOffsetInParent(headSection.renderNode.element, headNode, headOffset); - + const {headSection, headSectionOffset} = this.offsets; return {headSection, headSectionOffset}; } @@ -144,7 +112,7 @@ export default class Cursor { // moves cursor to marker moveToMarker(headMarker, headOffset=0, tailMarker=headMarker, tailOffset=headOffset) { - if (!headMarker) { throw new Error('Cannot move cursor to section without a marker'); } + if (!headMarker) { throw new Error('Cannot move cursor to marker without a marker'); } const headElement = headMarker.renderNode.element; const tailElement = tailMarker.renderNode.element; diff --git a/src/js/utils/cursor/position.js b/src/js/utils/cursor/position.js index 0cfbe22e2..f614e2c9c 100644 --- a/src/js/utils/cursor/position.js +++ b/src/js/utils/cursor/position.js @@ -1,6 +1,25 @@ import { detect } from 'content-kit-editor/utils/array-utils'; import { detectParentNode } from 'content-kit-editor/utils/dom-utils'; +// attempts to find a marker by walking up from the childNode +// and checking to find an element in the renderTree +// If a marker is found, return the marker's parent. +// This ensures we get the deepest section when there are nested +// sections (like ListItem < ListSection) +// FIXME obviously we would like to do this more declaratively, +// and not have two ways of finding the current focused section +function findSectionFromRenderTree(renderTree, childNode) { + let currentNode = childNode; + while (currentNode) { + let renderNode = renderTree.getElementRenderNode(currentNode); + if (renderNode) { + let marker = renderNode.postNode; + return marker.parent; + } + currentNode = currentNode.parentNode; + } +} + function findSectionContaining(sections, childNode) { const { result: section } = detectParentNode(childNode, node => { return detect(sections, section => { @@ -10,7 +29,7 @@ function findSectionContaining(sections, childNode) { return section; } -export default class Position { +const Position = class Position { constructor(section, offsetInSection=0) { let marker = null, offsetInMarker = null; @@ -28,10 +47,12 @@ export default class Position { this.marker = marker; this.offsetInMarker = offsetInMarker; } + isEqual(position) { return this.section === position.section && this.offsetInSection === position.offsetInSection; } + static fromNode(renderTree, sections, node, offsetInNode) { // Only markers are registered into the element/renderNode map let markerRenderNode = renderTree.getElementRenderNode(node); @@ -49,7 +70,9 @@ export default class Position { // Chrome/Safari using shift+ can create a selection with // a tag rather than a text node. This fixes that. // See https://github.com/bustlelabs/content-kit-editor/issues/56 - section = findSectionContaining(sections, node); + section = findSectionFromRenderTree(renderTree, node) || + findSectionContaining(sections, node); + if (section) { offsetInSection = 0; } @@ -57,4 +80,6 @@ export default class Position { return new Position(section, offsetInSection); } -} +}; + +export default Position; diff --git a/tests/acceptance/editor-list-test.js b/tests/acceptance/editor-list-test.js new file mode 100644 index 000000000..ad716fd12 --- /dev/null +++ b/tests/acceptance/editor-list-test.js @@ -0,0 +1,208 @@ +import { Editor } from 'content-kit-editor'; +import Helpers from '../test-helpers'; + +const { module, test } = Helpers; + +let editor, editorElement; + +function createEditorWithListMobiledoc() { + const mobiledoc = Helpers.mobiledoc.build(({post, listSection, listItem, marker}) => + post([ + listSection('ul', [ + listItem([marker('first item')]), + listItem([marker('second item')]) + ]) + ]) + ); + + editor = new Editor({mobiledoc}); + editor.render(editorElement); +} + +module('Acceptance: Editor: Lists', { + beforeEach() { + editorElement = document.createElement('div'); + editorElement.setAttribute('id', 'editor'); + $('#qunit-fixture').append(editorElement); + }, + afterEach() { + if (editor) { editor.destroy(); } + } +}); + +test('can type in middle of a list item', (assert) => { + createEditorWithListMobiledoc(); + + const listItem = $('#editor li:contains(first item)')[0]; + assert.ok(!!listItem, 'precond - has li'); + + Helpers.dom.moveCursorTo(listItem.childNodes[0], 'first'.length); + Helpers.dom.insertText('X'); + + assert.hasElement('#editor li:contains(firstX item)', 'inserts text at right spot'); +}); + +test('can type at end of a list item', (assert) => { + createEditorWithListMobiledoc(); + + const listItem = $('#editor li:contains(first item)')[0]; + assert.ok(!!listItem, 'precond - has li'); + + Helpers.dom.moveCursorTo(listItem.childNodes[0], 'first item'.length); + Helpers.dom.insertText('X'); + + assert.hasElement('#editor li:contains(first itemX)', 'inserts text at right spot'); +}); + +test('can type at start of a list item', (assert) => { + createEditorWithListMobiledoc(); + + const listItem = $('#editor li:contains(first item)')[0]; + assert.ok(!!listItem, 'precond - has li'); + + Helpers.dom.moveCursorTo(listItem.childNodes[0], 0); + Helpers.dom.insertText('X'); + + assert.hasElement('#editor li:contains(Xfirst item)', 'inserts text at right spot'); +}); + +test('can delete selection across list items', (assert) => { + createEditorWithListMobiledoc(); + + const listItem = $('#editor li:contains(first item)')[0]; + assert.ok(!!listItem, 'precond - has li1'); + + const listItem2 = $('#editor li:contains(second item)')[0]; + assert.ok(!!listItem2, 'precond - has li2'); + + Helpers.dom.selectText(' item', listItem, 'secon', listItem2); + Helpers.dom.triggerDelete(editor); + + assert.hasElement('#editor li:contains(d item)', 'results in correct text'); + assert.equal($('#editor li').length, 1, 'only 1 remaining li'); +}); + +test('can exit list section altogether by deleting', (assert) => { + createEditorWithListMobiledoc(); + + const listItem2 = $('#editor li:contains(second item)')[0]; + assert.ok(!!listItem2, 'precond - has listItem2'); + + Helpers.dom.moveCursorTo(listItem2.childNodes[0], 0); + Helpers.dom.triggerDelete(editor); + + assert.hasElement('#editor li:contains(first item)', 'still has first item'); + assert.hasNoElement('#editor li:contains(second item)', 'second li is gone'); + assert.hasElement('#editor p:contains(second item)', 'second li becomes p'); + + Helpers.dom.insertText('X'); + + assert.hasElement('#editor p:contains(Xsecond item)', 'new text is in right spot'); +}); + +test('can split list item with ', (assert) => { + createEditorWithListMobiledoc(); + + let li = $('#editor li:contains(first item)')[0]; + assert.ok(!!li, 'precond'); + + Helpers.dom.moveCursorTo(li.childNodes[0], 'fir'.length); + Helpers.dom.triggerEnter(editor); + + assert.hasNoElement('#editor li:contains(first item)', 'first item is split'); + assert.hasElement('#editor li:contains(fir)', 'has split "fir" li'); + assert.hasElement('#editor li:contains(st item)', 'has split "st item" li'); + assert.hasElement('#editor li:contains(second item)', 'has unchanged last li'); + assert.equal($('#editor li').length, 3, 'has 3 lis'); + + // hitting enter can create the right DOM but put the AT out of sync with the + // renderTree, so we must hit enter once more to fully test this + + li = $('#editor li:contains(fir)')[0]; + assert.ok(!!li, 'precond - has "fir"'); + Helpers.dom.moveCursorTo(li.childNodes[0], 'fi'.length); + Helpers.dom.triggerEnter(editor); + + assert.hasNoElement('#editor li:contains(fir)'); + assert.hasElement('#editor li:contains(fi)', 'has split "fi" li'); + assert.hasElement('#editor li:contains(r)', 'has split "r" li'); + assert.equal($('#editor li').length, 4, 'has 4 lis'); +}); + +test('can hit enter at end of list item to add new item', (assert) => { + createEditorWithListMobiledoc(); + + const li = $('#editor li:contains(first item)')[0]; + assert.ok(!!li, 'precond'); + + Helpers.dom.moveCursorTo(li.childNodes[0], 'first item'.length); + Helpers.dom.triggerEnter(editor); + + assert.equal($('#editor li').length, 3, 'adds a new li'); + let newLi = $('#editor li:eq(1)'); + assert.equal(newLi.text(), '', 'new li has no text'); + + Helpers.dom.insertText('X'); + assert.hasElement('#editor li:contains(X)', 'text goes in right spot'); + + 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'); +}); + +test('hitting enter to add list item, deleting to remove it, adding new list item, exiting list and typing', (assert) => { + createEditorWithListMobiledoc(); + + let li = $('#editor li:contains(first item)')[0]; + assert.ok(!!li, 'precond'); + + Helpers.dom.moveCursorTo(li.childNodes[0], 'first item'.length); + Helpers.dom.triggerEnter(editor); + + assert.equal($('#editor li').length, 3, 'adds a new li'); + + Helpers.dom.triggerDelete(editor); + + assert.equal($('#editor li').length, 2, 'removes middle, empty li after delete'); + assert.equal($('#editor p').length, 1, 'adds a new paragraph section where delete happened'); + + li = $('#editor li:contains(first item)')[0]; + Helpers.dom.moveCursorTo(li.childNodes[0], 'first item'.length); + Helpers.dom.triggerEnter(editor); + + assert.equal($('#editor li').length, 3, 'adds a new li after enter again'); + + Helpers.dom.triggerEnter(editor); + + assert.equal($('#editor li').length, 2, 'removes newly added li after enter on last list item'); + assert.equal($('#editor p').length, 2, 'adds a second p section'); + + Helpers.dom.insertText('X'); + + assert.hasElement('#editor p:eq(0):contains(X)', 'inserts text in right spot'); +}); + +test('hitting enter at empty last list item exists list', (assert) => { + createEditorWithListMobiledoc(); + + assert.equal($('#editor p').length, 0, 'precond - no ps'); + + const li = $('#editor li:contains(second item)')[0]; + assert.ok(!!li, 'precond'); + + Helpers.dom.moveCursorTo(li.childNodes[0], 'second item'.length); + Helpers.dom.triggerEnter(editor); + + assert.equal($('#editor li').length, 3, 'precond - adds a third li'); + + Helpers.dom.triggerEnter(editor); + + assert.equal($('#editor li').length, 2, 'removes empty li'); + assert.equal($('#editor p').length, 1, 'adds 1 new p'); + assert.equal($('#editor p').text(), '', 'p has no text'); + + Helpers.dom.insertText('X'); + assert.hasElement('#editor p:contains(X)', 'text goes in right spot'); +}); diff --git a/tests/acceptance/editor-sections-test.js b/tests/acceptance/editor-sections-test.js index 75309bc12..6b46b7164 100644 --- a/tests/acceptance/editor-sections-test.js +++ b/tests/acceptance/editor-sections-test.js @@ -3,7 +3,7 @@ import Helpers from '../test-helpers'; import { MOBILEDOC_VERSION } from 'content-kit-editor/renderers/mobiledoc'; import { NO_BREAK_SPACE } from 'content-kit-editor/renderers/editor-dom'; -const { test, module } = QUnit; +const { test, module } = Helpers; let fixture, editor, editorElement; const mobileDocWith1Section = { @@ -282,28 +282,33 @@ test('keystroke of delete when cursor is after only char in only marker of secti assert.hasElement('#editor p:eq(0):contains(X)', 'text is added back to section'); }); -Helpers.skipInPhantom('keystroke of character in empty section adds character, moves cursor', (assert) => { +test('keystroke of character in empty section adds character, moves cursor', (assert) => { editor = new Editor({mobiledoc: mobileDocWithNoCharacter}); editor.render(editorElement); const getTextNode = () => editor.element. - firstChild. // section - firstChild; // marker + childNodes[0]. // section + childNodes[0]; // marker let textNode = getTextNode(); assert.ok(!!textNode, 'precond - gets text node'); Helpers.dom.moveCursorTo(textNode, 0); - const key = "M"; - const keyCode = key.charCodeAt(0); - Helpers.dom.triggerKeyEvent(document, 'keydown', keyCode, key); + const letter = 'M'; + Helpers.dom.insertText(letter); - textNode = getTextNode(); - assert.equal(textNode.textContent, key, 'adds character'); - assert.equal(textNode.textContent.length, 1); + assert.equal(getTextNode().textContent, letter, 'adds character'); + assert.equal(getTextNode().textContent.length, 1); assert.deepEqual(Helpers.dom.getCursorPosition(), - {node: textNode, offset: 1}, - `cursor moves to end of ${key} text node`); + {node: getTextNode(), offset: 1}, + `cursor moves to end of ${letter} text node`); + + const otherLetter = 'X'; + Helpers.dom.insertText(otherLetter); + + assert.equal(getTextNode().textContent, `${letter}${otherLetter}`, + 'adds character in the correct spot'); + assert.equal(getTextNode().textContent.length, letter.length + otherLetter.length); }); test('keystroke of delete at start of section joins with previous section', (assert) => { diff --git a/tests/helpers/dom.js b/tests/helpers/dom.js index 56e81ff03..63f0bf70c 100644 --- a/tests/helpers/dom.js +++ b/tests/helpers/dom.js @@ -96,7 +96,7 @@ _buildDOM.text = (string) => { /** * Usage: - * makeDOM(t => + * build(t => * t('div', attributes={}, children=[ * t('b', {}, [ * t.text('I am a bold text node') @@ -104,7 +104,7 @@ _buildDOM.text = (string) => { * ]) * ); */ -function makeDOM(tree) { +function build(tree) { return tree(_buildDOM); } @@ -140,6 +140,7 @@ function getCursorPosition() { } function triggerDelete(editor) { + if (!editor) { throw new Error('Must pass `editor` to `triggerDelete`'); } if (isPhantom()) { // simulate deletion for phantomjs let event = { preventDefault() {} }; @@ -150,6 +151,7 @@ function triggerDelete(editor) { } function triggerEnter(editor) { + if (!editor) { throw new Error('Must pass `editor` to `triggerEnter`'); } if (isPhantom()) { // simulate event when testing with phantom let event = { preventDefault() {} }; @@ -169,7 +171,7 @@ const DOMHelper = { clearSelection, triggerEvent, triggerKeyEvent, - makeDOM, + build, KEY_CODES, getCursorPosition, getSelectedText, diff --git a/tests/helpers/mobiledoc.js b/tests/helpers/mobiledoc.js index e6b694ac9..54e4cb82d 100644 --- a/tests/helpers/mobiledoc.js +++ b/tests/helpers/mobiledoc.js @@ -3,7 +3,7 @@ import MobiledocRenderer from 'content-kit-editor/renderers/mobiledoc'; /* * usage: - * makeMD(({post, section, marker, markup}) => + * build(({post, section, marker, markup}) => * post([ * section('P', [ * marker('some text', [markup('B')]) diff --git a/tests/helpers/post-abstract.js b/tests/helpers/post-abstract.js index bbbe3c517..a048055a7 100644 --- a/tests/helpers/post-abstract.js +++ b/tests/helpers/post-abstract.js @@ -13,12 +13,16 @@ import PostNodeBuilder from 'content-kit-editor/models/post-node-builder'; function build(treeFn) { let builder = new PostNodeBuilder(); - const post = (...args) => builder.createPost(...args); - const markupSection = (...args) => builder.createMarkupSection(...args); - const markup = (...args) => builder.createMarkup(...args); - const marker = (...args) => builder.createMarker(...args); + const simpleBuilder = { + post : (...args) => builder.createPost(...args), + markupSection : (...args) => builder.createMarkupSection(...args), + markup : (...args) => builder.createMarkup(...args), + marker : (...args) => builder.createMarker(...args), + listSection : (...args) => builder.createListSection(...args), + listItem : (...args) => builder.createListItem(...args) + }; - return treeFn({post, markupSection, markup, marker}); + return treeFn(simpleBuilder); } export default { diff --git a/tests/test-helpers.js b/tests/test-helpers.js index 5e3c19c69..ae0880d35 100644 --- a/tests/test-helpers.js +++ b/tests/test-helpers.js @@ -7,7 +7,24 @@ import skipInPhantom from './helpers/skip-in-phantom'; import MobiledocHelpers from './helpers/mobiledoc'; import PostAbstract from './helpers/post-abstract'; -const { test } = QUnit; +const { test:qunitTest, module } = QUnit; + +QUnit.config.urlConfig.push({ + id: 'debugTest', + label: 'Debug Test' +}); + +const test = (msg, callback) => { + let originalCallback = callback; + callback = (...args) => { + if (QUnit.config.debugTest) { + debugger; // jshint ignore:line + } + originalCallback(...args); + }; + qunitTest(msg, callback); +}; + function skip(message) { message = `[SKIPPED] ${message}`; test(message, (assert) => assert.ok(true)); @@ -19,5 +36,7 @@ export default { skipInPhantom, mobiledoc: MobiledocHelpers, postAbstract: PostAbstract, - skip + skip, + test, + module }; diff --git a/tests/unit/editor/editor-test.js b/tests/unit/editor/editor-test.js index b1b89810a..0edd4f2a2 100644 --- a/tests/unit/editor/editor-test.js +++ b/tests/unit/editor/editor-test.js @@ -8,7 +8,7 @@ const { module, test } = window.QUnit; let fixture, editorElement, editor; module('Unit: Editor', { - beforeEach: function() { + beforeEach() { fixture = document.getElementById('qunit-fixture'); editorElement = document.createElement('div'); editorElement.id = 'editor1'; diff --git a/tests/unit/parsers/mobiledoc-test.js b/tests/unit/parsers/mobiledoc-test.js index 57ee1855c..923a872e6 100644 --- a/tests/unit/parsers/mobiledoc-test.js +++ b/tests/unit/parsers/mobiledoc-test.js @@ -151,3 +151,31 @@ test('#parse doc with custom card type', (assert) => { post ); }); + +test('#parse a mobile doc with list-section and list-item', (assert) => { + const mobiledoc = { + version: MOBILEDOC_VERSION, + sections: [ + [], + [ + [3, 'ul', [ + [[[], 0, "first item"]], + [[[], 0, "second item"]] + ]] + ] + ] + }; + + const parsed = parser.parse(mobiledoc); + + const items = [ + builder.createListItem([builder.createMarker('first item')]), + builder.createListItem([builder.createMarker('second item')]) + ]; + const section = builder.createListSection('ul', items); + post.sections.append(section); + assert.deepEqual( + parsed, + post + ); +}); diff --git a/tests/unit/parsers/post-test.js b/tests/unit/parsers/post-test.js index a80dad8cd..72a633825 100644 --- a/tests/unit/parsers/post-test.js +++ b/tests/unit/parsers/post-test.js @@ -1,10 +1,11 @@ -const {module, test} = QUnit; - import PostParser from 'content-kit-editor/parsers/post'; import PostNodeBuilder from 'content-kit-editor/models/post-node-builder'; import Helpers from '../../test-helpers'; +import { Editor } from 'content-kit-editor'; + +const {module, test} = Helpers; -let builder, parser; +let builder, parser, editor; module('Unit: Parser: PostParser', { beforeEach() { @@ -14,11 +15,15 @@ module('Unit: Parser: PostParser', { afterEach() { builder = null; parser = null; + if (editor) { + editor.destroy(); + editor = null; + } } }); test('#parse can parse a section element', (assert) => { - let element = Helpers.dom.makeDOM(t => + let element = Helpers.dom.build(t => t('div', {}, [ t('p', {}, [ t.text('some text') @@ -36,7 +41,7 @@ test('#parse can parse a section element', (assert) => { }); test('#parse can parse multiple elements', (assert) => { - let element = Helpers.dom.makeDOM(t => + const element = Helpers.dom.build(t => t('div', {}, [ t('p', {}, [ t.text('some text') @@ -59,3 +64,48 @@ test('#parse can parse multiple elements', (assert) => { assert.equal(s2.markers.head.value, 'some other text'); }); +test('editor#reparse catches changes to section', (assert) => { + const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => + post([ + markupSection('p', [marker('the marker')]) + ]) + ); + editor = new Editor({mobiledoc}); + const editorElement = $('
')[0]; + $('#qunit-fixture').append(editorElement); + editor.render(editorElement); + + assert.hasElement('#editor p:contains(the marker)', 'precond - rendered correctly'); + + const p = $('#editor p:eq(0)')[0]; + p.childNodes[0].textContent = 'the NEW marker'; + + editor.reparse(); + + const section = editor.post.sections.head; + assert.equal(section.text, 'the NEW marker'); +}); + +test('editor#reparse catches changes to list section', (assert) => { + const mobiledoc = Helpers.mobiledoc.build(({post, listSection, listItem, marker}) => + post([ + listSection('ul', [ + listItem([marker('the list item')]) + ]) + ]) + ); + editor = new Editor({mobiledoc}); + const editorElement = $('
')[0]; + $('#qunit-fixture').append(editorElement); + editor.render(editorElement); + + assert.hasElement('#editor li:contains(list item)', 'precond - rendered correctly'); + + const li = $('#editor li:eq(0)')[0]; + li.childNodes[0].textContent = 'the NEW list item'; + + editor.reparse(); + + const listItem = editor.post.sections.head.items.head; + assert.equal(listItem.text, 'the NEW list item'); +}); diff --git a/tests/unit/parsers/section-test.js b/tests/unit/parsers/section-test.js index 0ec51be26..f76af4dc3 100644 --- a/tests/unit/parsers/section-test.js +++ b/tests/unit/parsers/section-test.js @@ -17,7 +17,7 @@ module('Unit: Parser: SectionParser', { }); test('#parse parses simple dom', (assert) => { - let element = Helpers.dom.makeDOM(t => + let element = Helpers.dom.build(t => t('p', {}, [ t.text('hello there'), t('b', {}, [ @@ -37,7 +37,7 @@ test('#parse parses simple dom', (assert) => { }); test('#parse parses nested markups', (assert) => { - let element = Helpers.dom.makeDOM(t => + let element = Helpers.dom.build(t => t('p', {}, [ t('b', {}, [ t.text('i am bold'), @@ -63,7 +63,7 @@ test('#parse parses nested markups', (assert) => { }); test('#parse ignores non-markup elements like spans', (assert) => { - let element = Helpers.dom.makeDOM(t => + let element = Helpers.dom.build(t => t('p', {}, [ t('span', {}, [ t.text('i was in span') @@ -80,7 +80,7 @@ test('#parse ignores non-markup elements like spans', (assert) => { }); test('#parse reads attributes', (assert) => { - let element = Helpers.dom.makeDOM(t => + let element = Helpers.dom.build(t => t('p', {}, [ t('a', {href: 'google.com'}, [ t.text('i am a link') @@ -96,7 +96,7 @@ test('#parse reads attributes', (assert) => { }); test('#parse joins contiguous text nodes separated by non-markup elements', (assert) => { - let element = Helpers.dom.makeDOM(t => + let element = Helpers.dom.build(t => t('p', {}, [ t('span', {}, [ t.text('span 1') diff --git a/tests/unit/renderers/editor-dom-test.js b/tests/unit/renderers/editor-dom-test.js index 507256fee..7c26630aa 100644 --- a/tests/unit/renderers/editor-dom-test.js +++ b/tests/unit/renderers/editor-dom-test.js @@ -1,8 +1,9 @@ import PostNodeBuilder from 'content-kit-editor/models/post-node-builder'; -const { module, test } = window.QUnit; import Renderer from 'content-kit-editor/renderers/editor-dom'; import RenderNode from 'content-kit-editor/models/render-node'; import RenderTree from 'content-kit-editor/models/render-tree'; +import Helpers from '../../test-helpers'; +const { module, test } = Helpers; const DATA_URL = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="; let builder; @@ -19,7 +20,7 @@ module("Unit: Renderer: Editor-Dom", { } }); -test("It renders a dirty post", (assert) => { +test("renders a dirty post", (assert) => { /* * renderTree is: * @@ -37,7 +38,7 @@ test("It renders a dirty post", (assert) => { assert.equal(renderTree.node.element.tagName, 'DIV', 'renderTree renders element for post'); }); -test("It renders a dirty post with un-rendered sections", (assert) => { +test("renders a dirty post with un-rendered sections", (assert) => { let post = builder.createPost(); let sectionA = builder.createMarkupSection('P'); post.sections.append(sectionA); @@ -77,7 +78,7 @@ test("It renders a dirty post with un-rendered sections", (assert) => { section: (builder) => builder.createCardSection('new-card') } ].forEach((testInfo) => { - test(`Remove nodes with ${testInfo.name} section`, (assert) => { + test(`remove nodes with ${testInfo.name} section`, (assert) => { let post = builder.createPost(); let section = testInfo.section(builder); post.sections.append(section); @@ -441,6 +442,115 @@ test('contiguous markers have overlapping markups', (assert) => { '

WXYZ

'); }); +test('renders and rerenders list items', (assert) => { + const post = Helpers.postAbstract.build(({post, listSection, listItem, marker}) => + post([ + listSection('ul', [ + listItem([marker('first item')]), + listItem([marker('second item')]) + ]) + ]) + ); + + const node = new RenderNode(post); + const renderTree = new RenderTree(node); + node.renderTree = renderTree; + render(renderTree); + + const expectedDOM = Helpers.dom.build(t => + t('ul', {}, [ + t('li', {}, [t.text('first item')]), + t('li', {}, [t.text('second item')]) + ]) + ); + const expectedHTML = expectedDOM.outerHTML; + + assert.equal(node.element.innerHTML, expectedHTML, 'correct html on initial render'); + + // test rerender after dirtying list section + const listSection = post.sections.head; + listSection.renderNode.markDirty(); + render(renderTree); + assert.equal(node.element.innerHTML, expectedHTML, 'correct html on rerender after dirtying list-section'); + + // test rerender after dirtying list item + const listItem = post.sections.head.items.head; + listItem.renderNode.markDirty(); + render(renderTree); + + assert.equal(node.element.innerHTML, expectedHTML, 'correct html on rerender after diryting list-item'); +}); + +test('removes list items', (assert) => { + const post = Helpers.postAbstract.build(({post, listSection, listItem, marker}) => + post([ + listSection('ul', [ + listItem([marker('first item')]), + listItem([marker('second item')]), + listItem([marker('third item')]) + ]) + ]) + ); + + const node = new RenderNode(post); + const renderTree = new RenderTree(node); + node.renderTree = renderTree; + render(renderTree); + + // return HTML for a list with the given items + const htmlWithItems = (itemTexts) => { + const expectedDOM = Helpers.dom.build(t => + t('ul', {}, itemTexts.map(text => t('li', {}, [t.text(text)]))) + ); + return expectedDOM.outerHTML; + }; + + const listItem2 = post.sections.head. // listSection + items.objectAt(1); // li + listItem2.renderNode.scheduleForRemoval(); + render(renderTree); + + assert.equal(node.element.innerHTML, + htmlWithItems(['first item', 'third item']), + 'removes middle list item'); + + const listItemLast = post.sections.head. // listSection + items.tail; + listItemLast.renderNode.scheduleForRemoval(); + render(renderTree); + + assert.equal(node.element.innerHTML, + htmlWithItems(['first item']), + 'removes last list item'); +}); + +test('removes list sections', (assert) => { + const post = Helpers.postAbstract.build(({post, listSection, markupSection, listItem, marker}) => + post([ + markupSection('p', [marker('something')]), + listSection('ul', [ + listItem([marker('first item')]) + ]) + ]) + ); + + const node = new RenderNode(post); + const renderTree = new RenderTree(node); + node.renderTree = renderTree; + render(renderTree); + + const expectedDOM = Helpers.dom.build(t => + t('p', {}, [t.text('something')]) + ); + const expectedHTML = expectedDOM.outerHTML; + + const listSection = post.sections.objectAt(1); + listSection.renderNode.scheduleForRemoval(); + render(renderTree); + + assert.equal(node.element.innerHTML, expectedHTML, 'removes list section'); +}); + /* test("It renders a renderTree with rendered dirty section", (assert) => { /* diff --git a/tests/unit/renderers/mobiledoc-test.js b/tests/unit/renderers/mobiledoc-test.js index 289a809d2..5c3acab4c 100644 --- a/tests/unit/renderers/mobiledoc-test.js +++ b/tests/unit/renderers/mobiledoc-test.js @@ -134,3 +134,26 @@ test('renders a post with card', (assert) => { ] }); }); + +test('renders a post with a list', (assert) => { + const items = [ + builder.createListItem([builder.createMarker('first item')]), + builder.createListItem([builder.createMarker('second item')]) + ]; + const section = builder.createListSection('ul', items); + const post = builder.createPost([section]); + + const mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + sections: [ + [], + [ + [3, 'ul', [ + [[[], 0, 'first item']], + [[[], 0, 'second item']] + ]] + ] + ] + }); +});