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']]
+ ]]
+ ]
+ ]
+ });
+});