- This mobiledoc will be loaded into the editor.
- You can change it and see the editor reload with the new contents.
- (If there is a JSON syntax error it will be ignored; if there is a parser
- error the editor may stop responding.)
-
- Select a preloaded mobiledoc here:
-
- Choose a mobiledoc...
- Simple mobiledoc
- simple card
- edit card
- input card
- selfie card
- mobiledoc with simple marker
- mobiledoc with multiple markers
- mobiledoc with attributed marker
-
-
-
editor
-
- The live-editing surface. Changes here are serialized to mobiledoc
- format and displayed to the right.
-
-
-
-
serialized mobiledoc
-
- When the editor updates, it prints its serialized mobiledoc here.
-
-
-
-
rendered mobiledoc (dom)
-
- This is the output of using the runtime (client-side)
- mobiledoc-dom-renderer
- on the serialized mobiledoc.
-
-
-
-
-
-
innerHTML of editor surface
-
-
-
-
-
rendered mobiledoc (html)
-
- This is the output of using the server-side
- mobiledoc-html-renderer
- on the serialized mobiledoc.
-
-
-
-
diff --git a/notes b/notes
new file mode 100644
index 000000000..2e925b053
--- /dev/null
+++ b/notes
@@ -0,0 +1,63 @@
+editor actions:
+ * hitting enter multiple times to create arbitrary space (prevent or allow plugin-based validation of the AST)
+ * maintain header hierarchy (no h2 without a prior h1, no h3 w/out prior h2, etc)
+
+abc|def|ghi
+
+i=0, length=0, offset=3
+i=1, length=3, offset=3
+
+length === offset
+
+
+abc
bold italic+bold bold2 def
+
+const PickColorCard = {
+ name: 'pick-color',
+ edit: {
+ setup(element, options, {save, cancel}, payload) {
+ // ^ env - an object of runtime options and hooks
+ let component = EditPickColorComponent.create(payload);
+ component.save = function(newPayload) {
+ save(newPayload);
+ };
+ component.cancel = cancel;
+ component.appendTo(element);
+ return {component};
+ },
+ teardown({component}) {
+ Ember.run(component,component.destroy);
+ }
+ },
+ render: {
+ setup(element, options, {edit}, payload) {
+ let component = PickColorComponent.create(payload);
+ component.appendTo(element);
+ if (options.mode === 'edit') {
+ $(element).click(function(){
+ window.popup(payload.editUrl);
+ });
+ }
+ return {component};
+ },
+ teardown({component}) {
+ Ember.run(component, component.destroy);
+ };
+ }
+};
+
+new ContentKit.Edtior(editorElement, cards: [
+ PickColorCard
+]});
+
+var domRenderer = new MobiledocDOMRenderer();
+var rendered = renderer.render(mobiledoc, {
+ cardOptions: { mode: 'highQuality' },
+ unknownCard(element, options, {name}, payload) {
+ // manage unknown name
+ // can only be rendered, has no teardown
+ },
+ cards: [
+ PickColorCard
+ ]
+});
diff --git a/src/js/commands/card.js b/src/js/commands/card.js
index 525f3d101..3a6fb2d49 100644
--- a/src/js/commands/card.js
+++ b/src/js/commands/card.js
@@ -3,16 +3,6 @@ import { inherit } from 'content-kit-utils';
function injectCardBlock(/* cardName, cardPayload, editor, index */) {
throw new Error('Unimplemented: BlockModel and Type.CARD are no longer things');
- // FIXME: Do we change the block model internal representation here?
- /*
- var cardBlock = BlockModel.createWithType(Type.CARD, {
- attributes: {
- name: cardName,
- payload: cardPayload
- }
- });
- editor.replaceBlock(cardBlock, index);
- */
}
function CardCommand() {
@@ -32,7 +22,6 @@ CardCommand.prototype = {
var cardName = 'pick-color';
var cardPayload = { options: ['red', 'blue'] };
injectCardBlock(cardName, cardPayload, editor, currentEditingIndex);
- editor.renderBlockAt(currentEditingIndex, true);
}
};
diff --git a/src/js/commands/oembed.js b/src/js/commands/oembed.js
index 1ec9b9d03..16ca133f8 100644
--- a/src/js/commands/oembed.js
+++ b/src/js/commands/oembed.js
@@ -56,14 +56,6 @@ OEmbedCommand.prototype.exec = function(url) {
embedIntent.show();
} else {
throw new Error('Unimplemented EmbedModel is not a thing');
- /*
- var embedModel = new EmbedModel(response);
- editorContext.insertBlock(embedModel, index);
- editorContext.renderBlockAt(index);
- if (embedModel.attributes.provider_name.toLowerCase() === 'twitter') {
- loadTwitterWidgets(editorContext.element);
- }
- */
}
}
});
diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js
index ee68a14e8..c5df579fe 100644
--- a/src/js/editor/editor.js
+++ b/src/js/editor/editor.js
@@ -17,15 +17,13 @@ import CardCommand from '../commands/card';
import Keycodes from '../utils/keycodes';
import {
getSelectionBlockElement,
- getCursorOffsetInElement,
- clearSelection,
- isSelectionInElement
+ getCursorOffsetInElement
} from '../utils/selection-utils';
import EventEmitter from '../utils/event-emitter';
import MobiledocParser from "../parsers/mobiledoc";
-import DOMParser from "../parsers/dom";
-import Renderer from 'content-kit-editor/renderers/editor-dom';
+import PostParser from '../parsers/post';
+import Renderer, { UNPRINTABLE_CHARACTER } from 'content-kit-editor/renderers/editor-dom';
import RenderTree from 'content-kit-editor/models/render-tree';
import MobiledocRenderer from '../renderers/mobiledoc';
@@ -33,11 +31,16 @@ import { toArray, mergeWithOptions } from 'content-kit-utils';
import {
detectParentNode,
clearChildNodes,
- forEachChildNode
} from '../utils/dom-utils';
+import {
+ forEach
+} from '../utils/array-utils';
import { getData, setData } from '../utils/element-utils';
import mixin from '../utils/mixin';
import EventListenerMixin from '../utils/event-listener';
+import Cursor from '../models/cursor';
+import { MARKUP_SECTION_TYPE } from '../models/markup-section';
+import { generateBuilder } from '../utils/post-builder';
const defaults = {
placeholder: 'Write here...',
@@ -73,17 +76,6 @@ const defaults = {
};
function bindContentEditableTypingListeners(editor) {
- editor.addEventListener(editor.element, 'keyup', function(e) {
- // Assure there is always a supported block tag, and not empty text nodes or divs.
- // On a carrage return, make sure to always generate a 'p' tag
- if (!getSelectionBlockElement() ||
- !editor.element.textContent ||
- (!e.shiftKey && e.which === Keycodes.ENTER) || (e.ctrlKey && e.which === Keycodes.M)) {
- // FIXME-IE 'p' tag doesn't work for formatBlock in IE see https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
- document.execCommand('formatBlock', false, 'p');
- }
- });
-
// On 'PASTE' sanitize and insert
editor.addEventListener(editor.element, 'paste', function(e) {
var data = e.clipboardData;
@@ -119,7 +111,7 @@ function bindAutoTypingListeners(editor) {
function handleSelection(editor) {
return () => {
- if (isSelectionInElement(editor.element)) {
+ if (editor.cursor.hasSelection()) {
editor.hasSelection();
} else {
editor.hasNoSelection();
@@ -148,11 +140,24 @@ function bindSelectionEvent(editor) {
}
function bindKeyListeners(editor) {
+ // escape key
editor.addEventListener(document, 'keyup', (event) => {
if (event.keyCode === Keycodes.ESC) {
editor.trigger('escapeKey');
}
});
+
+ editor.addEventListener(document, 'keydown', (event) => {
+ switch (event.keyCode) {
+ case Keycodes.BACKSPACE:
+ case Keycodes.DELETE:
+ editor.handleDeletion(event);
+ break;
+ case Keycodes.ENTER:
+ editor.handleNewline(event);
+ break;
+ }
+ });
}
function bindDragAndDrop(editor) {
@@ -195,7 +200,7 @@ class Editor {
// FIXME: This should merge onto this.options
mergeWithOptions(this, defaults, options);
- this._parser = new DOMParser();
+ this._parser = PostParser;
this._renderer = new Renderer(this.cards, this.unknownCardHandler, this.cardOptions);
this.applyClassName();
@@ -274,6 +279,131 @@ class Editor {
this._renderer.render(this._renderTree);
}
+ // FIXME ensure we handle deletion when there is a selection
+ handleDeletion(event) {
+ let {
+ leftRenderNode,
+ leftOffset
+ } = this.cursor.offsets;
+
+ // need to handle these cases:
+ // when cursor is:
+ // * A in the middle of a marker -- just delete the character
+ // * B offset is 0 and there is a previous marker
+ // * delete last char of previous marker
+ // * C offset is 0 and there is no previous marker
+ // * join this section with previous section
+
+ const currentMarker = leftRenderNode.postNode;
+ let nextCursorMarker = currentMarker;
+ let nextCursorOffset = leftOffset - 1;
+
+ // A: in the middle of a marker
+ if (leftOffset !== 0) {
+ currentMarker.deleteValueAtOffset(leftOffset-1);
+ if (currentMarker.length === 0 && currentMarker.section.markers.length > 1) {
+ leftRenderNode.scheduleForRemoval();
+
+ let isFirstRenderNode = leftRenderNode === leftRenderNode.parentNode.firstChild;
+ if (isFirstRenderNode) {
+ // move cursor to start of next node
+ nextCursorMarker = leftRenderNode.nextSibling.postNode;
+ nextCursorOffset = 0;
+ } else {
+ // move cursor to end of prev node
+ nextCursorMarker = leftRenderNode.previousSibling.postNode;
+ nextCursorOffset = leftRenderNode.previousSibling.postNode.length;
+ }
+ } else {
+ leftRenderNode.markDirty();
+ }
+ } else {
+ let currentSection = currentMarker.section;
+ let previousMarker = currentMarker.previousSibling;
+ if (previousMarker) { // (B)
+ let markerLength = previousMarker.length;
+ previousMarker.deleteValueAtOffset(markerLength - 1);
+ } else { // (C)
+ // possible previous sections:
+ // * none -- do nothing
+ // * markup section -- join to it
+ // * non-markup section (card) -- select it? delete it?
+ let previousSection = this.post.getPreviousSection(currentSection);
+ if (previousSection) {
+ let isMarkupSection = previousSection.type === MARKUP_SECTION_TYPE;
+
+ if (isMarkupSection) {
+ let previousSectionMarkerLength = previousSection.markers.length;
+ previousSection.join(currentSection);
+ previousSection.renderNode.markDirty();
+ currentSection.renderNode.scheduleForRemoval();
+
+ nextCursorMarker = previousSection.markers[previousSectionMarkerLength];
+ nextCursorOffset = 0;
+ /*
+ } else {
+ // card section: ??
+ */
+ }
+ } else { // no previous section -- do nothing
+ nextCursorMarker = currentMarker;
+ nextCursorOffset = 0;
+ }
+ }
+ }
+
+ this.rerender();
+
+ this.cursor.moveToNode(nextCursorMarker.renderNode.element,
+ nextCursorOffset);
+
+ this.trigger('update');
+ event.preventDefault();
+ }
+
+ handleNewline(event) {
+ const {
+ leftRenderNode,
+ rightRenderNode,
+ leftOffset
+ } = this.cursor.offsets;
+
+ // if there's no left/right nodes, we are probably not in the editor,
+ // or we have selected some non-marker thing like a card
+ if (!leftRenderNode || !rightRenderNode) { return; }
+
+ // FIXME handle when the selection is not collapsed, this code assumes it is
+ event.preventDefault();
+
+ const markerRenderNode = leftRenderNode;
+ const marker = markerRenderNode.postNode;
+ const section = marker.section;
+ const [leftMarker, rightMarker] = marker.split(leftOffset);
+
+ section.insertMarkerAfter(leftMarker, marker);
+ markerRenderNode.scheduleForRemoval();
+
+ const newSection = generateBuilder().generateMarkupSection('P');
+ newSection.appendMarker(rightMarker);
+
+ let nodeForMove = markerRenderNode.nextSibling;
+ while (nodeForMove) {
+ nodeForMove.scheduleForRemoval();
+ let movedMarker = nodeForMove.postNode.clone();
+ newSection.appendMarker(movedMarker);
+
+ nodeForMove = nodeForMove.nextSibling;
+ }
+
+ const post = this.post;
+ post.insertSectionAfter(newSection, section);
+
+ this.rerender();
+ this.trigger('update');
+
+ this.cursor.moveToSection(newSection);
+ }
+
hasSelection() {
if (!this._hasSelection) {
this.trigger('selection');
@@ -293,11 +423,25 @@ class Editor {
cancelSelection() {
if (this._hasSelection) {
// FIXME perhaps restore cursor position to end of the selection?
- clearSelection();
+ this.cursor.clearSelection();
this.hasNoSelection();
}
}
+ getActiveMarkers() {
+ const cursor = this.cursor;
+ return cursor.activeMarkers;
+ }
+
+ getActiveSections() {
+ const cursor = this.cursor;
+ return cursor.activeSections;
+ }
+
+ get cursor() {
+ return new Cursor(this);
+ }
+
getCurrentBlockIndex() {
var selectionEl = this.element || getSelectionBlockElement();
var blockElements = toArray(this.element.children);
@@ -312,29 +456,6 @@ class Editor {
return -1;
}
- insertBlock(block, index) {
- this.post.splice(index, 0, block);
- this.trigger('update');
- }
-
- removeBlockAt(index) {
- this.post.splice(index, 1);
- this.trigger('update');
- }
-
- replaceBlock(block, index) {
- this.post[index] = block;
- this.trigger('update');
- }
-
- renderBlockAt(/* index, replace */) {
- throw new Error('Unimplemented');
- }
-
- syncContentEditableBlocks() {
- throw new Error('Unimplemented');
- }
-
applyClassName() {
var editorClassName = 'ck-editor';
var editorClassNameRegExp = new RegExp(editorClassName);
@@ -355,28 +476,50 @@ class Editor {
}
}
+ /**
+ * types of input to handle:
+ * * delete from beginning of section
+ * joins 2 sections
+ * * delete when multiple sections selected
+ * removes wholly-selected sections,
+ * joins the partially-selected sections
+ * * hit enter (handled by capturing 'keydown' for enter key and `handleNewline`)
+ * if anything is selected, delete it first, then
+ * split the current marker at the cursor position,
+ * schedule removal of every marker after the split,
+ * create new section, append it to post
+ * append the after-split markers onto the new section
+ * rerender -- this should render the new section at the appropriate spot
+ */
handleInput() {
+ this.reparse();
+ this.trigger('update');
+ }
+
+ reparse() {
// find added sections
let sectionsInDOM = [];
let newSections = [];
let previousSection;
- forEachChildNode(this.element, (node) => {
+
+ forEach(this.element.childNodes, (node) => {
let sectionRenderNode = this._renderTree.getElementRenderNode(node);
if (!sectionRenderNode) {
- let section = this._parser.parseSection(
- previousSection,
- node
- );
+ 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();
if (previousSection) {
+ // insert after existing section
this.post.insertSectionAfter(section, previousSection);
this._renderTree.node.insertAfter(sectionRenderNode, previousSection.renderNode);
} else {
+ // prepend at beginning (first section)
this.post.prependSection(section);
this._renderTree.node.insertAfter(sectionRenderNode, null);
}
@@ -402,23 +545,53 @@ class Editor {
// reparse the section(s) with the cursor
const sectionsWithCursor = this.getSectionsWithCursor();
- // FIXME: This is a hack to ensure a previous section is parsed when the
- // user presses enter (or pastes a newline)
- let firstSection = sectionsWithCursor[0];
- if (firstSection) {
- let previousSection = this.post.getPreviousSection(firstSection);
- if (previousSection) {
- sectionsWithCursor.unshift(previousSection);
- }
- }
sectionsWithCursor.forEach((section) => {
if (newSections.indexOf(section) === -1) {
this.reparseSection(section);
}
});
+ let {
+ leftRenderNode,
+ leftOffset,
+ rightRenderNode,
+ rightOffset
+ } = this.cursor.offsets;
+
+ // The cursor will lose its textNode if we have parsed (and thus rerendered)
+ // 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 = (leftRenderNode &&
+ sectionsWithCursor.indexOf(leftRenderNode.postNode.section) !== -1);
+
+ if (resetCursor) {
+ let unprintableOffset = leftRenderNode.element.textContent.indexOf(UNPRINTABLE_CHARACTER);
+ if (unprintableOffset !== -1) {
+ leftRenderNode.markDirty();
+ if (unprintableOffset < leftOffset) {
+ // FIXME: we should move backward/forward some number of characters
+ // with a method on markers that returns the relevent marker and
+ // offset (may not be the marker it was called with);
+ leftOffset--;
+ rightOffset--;
+ }
+ }
+ }
+
this.rerender();
this.trigger('update');
+
+ if (resetCursor) {
+ this.cursor.moveToNode(
+ leftRenderNode.element,
+ leftOffset,
+ rightRenderNode.element,
+ rightOffset
+ );
+ }
}
getSectionsWithCursor() {
@@ -438,7 +611,10 @@ class Editor {
let { startContainer:startElement, endContainer:endElement } = range;
let getElementRenderNode = (e) => {
- return this._renderTree.getElementRenderNode(e);
+ let node = this._renderTree.getElementRenderNode(e);
+ if (node && node.postNode.type === MARKUP_SECTION_TYPE) {
+ return node;
+ }
};
let { result:startRenderNode } = detectParentNode(startElement, getElementRenderNode);
let { result:endRenderNode } = detectParentNode(endElement, getElementRenderNode);
@@ -454,17 +630,7 @@ class Editor {
}
reparseSection(section) {
- let sectionRenderNode = section.renderNode;
- let sectionElement = sectionRenderNode.element;
- let previousSection = this.post.getPreviousSection(section);
-
- var newSection = this._parser.parseSection(
- previousSection,
- sectionElement
- );
- section.markers = newSection.markers;
-
- this.trigger('update');
+ this._parser.reparseSection(section, this._renderTree);
}
serialize() {
diff --git a/src/js/models/card.js b/src/js/models/card.js
new file mode 100644
index 000000000..820ac42df
--- /dev/null
+++ b/src/js/models/card.js
@@ -0,0 +1,9 @@
+export const CARD_TYPE = 'card-section';
+
+export default class Card {
+ constructor(name, payload) {
+ this.name = name;
+ this.payload = payload;
+ this.type = CARD_TYPE;
+ }
+}
diff --git a/src/js/models/cursor.js b/src/js/models/cursor.js
new file mode 100644
index 000000000..870ba68cc
--- /dev/null
+++ b/src/js/models/cursor.js
@@ -0,0 +1,180 @@
+import {
+ detect
+} from '../utils/array-utils';
+
+import {
+ isSelectionInElement,
+ clearSelection
+} from '../utils/selection-utils';
+
+import {
+ detectParentNode,
+ containsNode,
+ walkTextNodes
+} from '../utils/dom-utils';
+
+const Cursor = class Cursor {
+ constructor(editor) {
+ this.editor = editor;
+ this.renderTree = editor._renderTree;
+ this.post = editor.post;
+ }
+
+ hasSelection() {
+ const parentElement = this.editor.element;
+ return isSelectionInElement(parentElement);
+ }
+
+ clearSelection() {
+ clearSelection();
+ }
+
+ get selection() {
+ return window.getSelection();
+ }
+
+ /**
+ * the offset from the left edge of the section
+ */
+ get leftOffset() {
+ return this.offsets.leftOffset;
+ }
+
+ get offsets() {
+ let leftNode, rightNode,
+ leftOffset, rightOffset;
+ const { anchorNode, focusNode, anchorOffset, focusOffset } = this.selection;
+
+ const position = anchorNode.compareDocumentPosition(focusNode);
+
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
+ leftNode = anchorNode; rightNode = focusNode;
+ leftOffset = anchorOffset; rightOffset = focusOffset;
+ } else if (position & Node.DOCUMENT_POSITION_PRECEDING) {
+ leftNode = focusNode; rightNode = anchorNode;
+ leftOffset = focusOffset; rightOffset = anchorOffset;
+ } else { // same node
+ leftNode = anchorNode;
+ rightNode = focusNode;
+ leftOffset = Math.min(anchorOffset, focusOffset);
+ rightOffset = Math.max(anchorOffset, focusOffset);
+ }
+
+ const leftRenderNode = this.renderTree.elements.get(leftNode),
+ rightRenderNode = this.renderTree.elements.get(rightNode);
+
+ return {
+ leftNode,
+ rightNode,
+ leftOffset,
+ rightOffset,
+ leftRenderNode,
+ rightRenderNode
+ };
+ }
+
+ get activeMarkers() {
+ const firstSection = this.activeSections[0];
+ if (!firstSection) { return []; }
+ const firstSectionElement = firstSection.renderNode.element;
+
+ const {
+ leftNode, rightNode,
+ leftOffset, rightOffset
+ } = this.offsets;
+
+ let textLeftOffset = 0,
+ textRightOffset = 0,
+ foundLeft = false,
+ foundRight = false;
+
+ walkTextNodes(firstSectionElement, (textNode) => {
+ let textLength = textNode.textContent.length;
+
+ if (!foundLeft) {
+ if (containsNode(leftNode, textNode)) {
+ textLeftOffset += leftOffset;
+ foundLeft = true;
+ } else {
+ textLeftOffset += textLength;
+ }
+ }
+ if (!foundRight) {
+ if (containsNode(rightNode, textNode)) {
+ textRightOffset += rightOffset;
+ foundRight = true;
+ } else {
+ textRightOffset += textLength;
+ }
+ }
+ });
+
+ // get section element
+ // walk it until we find one containing the left node, adding up textContent length along the way
+ // add the selection offset in the left node -- this is the offset in the parent textContent
+ // repeat for right node (subtract the remaining chars after selection offset) -- this is the end offset
+ //
+ // walk the section's markers, adding up length. Each marker with length >= offset and <= end offset is active
+
+ const leftMarker = firstSection.markerContaining(textLeftOffset, true);
+ const rightMarker = firstSection.markerContaining(textRightOffset, false);
+
+ const leftMarkerIndex = firstSection.markers.indexOf(leftMarker),
+ rightMarkerIndex = firstSection.markers.indexOf(rightMarker) + 1;
+
+ return firstSection.markers.slice(leftMarkerIndex, rightMarkerIndex);
+ }
+
+ get activeSections() {
+ const { sections } = this.post;
+ const selection = this.selection;
+ const { rangeCount } = selection;
+ const range = rangeCount > 0 && selection.getRangeAt(0);
+
+ if (!range) { throw new Error('Unable to get activeSections because no range'); }
+
+ const { startContainer, endContainer } = range;
+ const isSectionElement = (element) => {
+ return detect(sections, (section) => {
+ return section.renderNode.element === element;
+ });
+ };
+ const {result:startSection} = detectParentNode(startContainer, isSectionElement);
+ const {result:endSection} = detectParentNode(endContainer, isSectionElement);
+
+ const startIndex = sections.indexOf(startSection),
+ endIndex = sections.indexOf(endSection) + 1;
+
+ return sections.slice(startIndex, endIndex);
+ }
+
+ // moves cursor to the start of the section
+ moveToSection(section) {
+ const marker = section.markers[0];
+ if (!marker) { throw new Error('Cannot move cursor to section without a marker'); }
+ const markerElement = marker.renderNode.element;
+
+ let r = document.createRange();
+ r.selectNode(markerElement);
+ r.collapse(true);
+ const selection = this.selection;
+ if (selection.rangeCount > 0) {
+ selection.removeAllRanges();
+ }
+ selection.addRange(r);
+ }
+
+ moveToNode(node, offset=0, endNode=node, endOffset=offset) {
+ let r = document.createRange();
+ r.setStart(node, offset);
+ r.setEnd(endNode, endOffset);
+ const selection = this.selection;
+ if (selection.rangeCount > 0) {
+ selection.removeAllRanges();
+ }
+ selection.addRange(r);
+ }
+};
+
+export default Cursor;
+
diff --git a/src/js/models/marker.js b/src/js/models/marker.js
index 80b262830..7c6b68280 100644
--- a/src/js/models/marker.js
+++ b/src/js/models/marker.js
@@ -13,6 +13,11 @@ const Marker = class Marker {
}
}
+ clone() {
+ const clonedMarkups = this.markups.slice();
+ return new this.constructor(this.value, clonedMarkups);
+ }
+
get length() {
return this.value.length;
}
@@ -29,6 +34,23 @@ const Marker = class Marker {
this.markups.push(markup);
}
+ removeMarkup(markup) {
+ const index = this.markups.indexOf(markup);
+ if (index === -1) { throw new Error('Cannot remove markup that is not there.'); }
+
+ this.markups.splice(index, 1);
+ }
+
+ // delete the character at this offset,
+ // update the value with the new value
+ deleteValueAtOffset(offset) {
+ const [ left, right ] = [
+ this.value.slice(0, offset),
+ this.value.slice(offset+1)
+ ];
+ this.value = left + right;
+ }
+
hasMarkup(tagName) {
tagName = tagName.toLowerCase();
return detect(this.markups, markup => markup.tagName === tagName);
diff --git a/src/js/models/markup-section.js b/src/js/models/markup-section.js
index 280527175..a9efbd0bd 100644
--- a/src/js/models/markup-section.js
+++ b/src/js/models/markup-section.js
@@ -9,15 +9,39 @@ export default class Section {
this.markers = [];
this.tagName = tagName || DEFAULT_TAG_NAME;
this.type = MARKUP_SECTION_TYPE;
+ this.element = null;
markers.forEach(m => this.appendMarker(m));
}
+ prependMarker(marker) {
+ marker.section = this;
+ this.markers.unshift(marker);
+ }
+
appendMarker(marker) {
marker.section = this;
this.markers.push(marker);
}
+ removeMarker(marker) {
+ const index = this.markers.indexOf(marker);
+ if (index === -1) {
+ throw new Error('Cannot remove not-found marker');
+ }
+ this.markers.splice(index, 1);
+ }
+
+ insertMarkerAfter(marker, previousMarker) {
+ const index = this.markers.indexOf(previousMarker);
+ if (index === -1) {
+ throw new Error('Cannot insert marker after: ' + previousMarker);
+ }
+
+ marker.section = this;
+ this.markers.splice(index + 1, 0, marker);
+ }
+
/**
* @return {Array} 2 new sections
*/
@@ -25,6 +49,13 @@ export default class Section {
let left = [], right = [], middle;
middle = this.markerContaining(offset);
+ // end of section
+ if (!middle) {
+ return [
+ new this.constructor(this.tagName, this.markers),
+ new this.constructor(this.tagName, [])
+ ];
+ }
const middleIndex = this.markers.indexOf(middle);
for (let i=0; i
this.appendMarker(m.clone()));
+ }
+
/**
* A marker contains this offset if:
* * The offset is between the marker's start and end
- * * it is the first marker and the offset is 0
- * * it is the last marker and the offset is >= total length of all the markers
- * * the offset is between two markers and it is the left marker (right-inclusive)
+ * * the offset is between two markers and this is the right marker (and leftInclusive is true)
+ * * the offset is between two markers and this is the left marker (and leftInclusive is false)
*
* @return {Marker} The marker that contains this offset
*/
- markerContaining(offset) {
+ markerContaining(offset, leftInclusive=true) {
var length=0, i=0;
if (offset === 0) { return this.markers[0]; }
@@ -63,6 +98,11 @@ export default class Section {
length += this.markers[i].length;
i++;
}
- return this.markers[i-1];
+
+ if (length > offset) {
+ return this.markers[i-1];
+ } else if (length === offset) {
+ return this.markers[leftInclusive ? i : i-1];
+ }
}
}
diff --git a/src/js/models/markup.js b/src/js/models/markup.js
index 28ebce75c..e9d6bc999 100644
--- a/src/js/models/markup.js
+++ b/src/js/models/markup.js
@@ -21,4 +21,9 @@ export default class Markup {
throw new Error(`Cannot create markup of tagName ${tagName}`);
}
}
+
+ static isValidElement(element) {
+ let tagName = element.tagName.toLowerCase();
+ return VALID_MARKUP_TAGNAMES.indexOf(tagName) !== -1;
+ }
}
diff --git a/src/js/models/render-node.js b/src/js/models/render-node.js
index b4533689d..da4fa7ae2 100644
--- a/src/js/models/render-node.js
+++ b/src/js/models/render-node.js
@@ -6,14 +6,21 @@ export default class RenderNode {
this.postNode = postNode;
this.firstChild = null;
+ this.lastChild = null;
this.nextSibling = null;
this.previousSibling = null;
}
scheduleForRemoval() {
this.isRemoved = true;
+ if (this.parentNode) {
+ this.parentNode.markDirty();
+ }
}
markDirty() {
this.isDirty = true;
+ if (this.parentNode) {
+ this.parentNode.markDirty();
+ }
}
markClean() {
this.isDirty = false;
diff --git a/src/js/parsers/post.js b/src/js/parsers/post.js
index fd124903a..631fb94c6 100644
--- a/src/js/parsers/post.js
+++ b/src/js/parsers/post.js
@@ -1,6 +1,17 @@
import Post from 'content-kit-editor/models/post';
+import { MARKUP_SECTION_TYPE } from '../models/markup-section';
import SectionParser from 'content-kit-editor/parsers/section';
import { forEach } from 'content-kit-editor/utils/array-utils';
+import { generateBuilder } from '../utils/post-builder';
+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 {
parse(element) {
@@ -13,7 +24,108 @@ export default {
return post;
},
- parseSection(element) {
+ parseSection(element, otherArg) {
+ if (!!otherArg) {
+ element = otherArg; // hack to deal with passed previousSection
+ }
return SectionParser.parse(element);
+ },
+
+ // 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;
+ }
+ const sectionElement = section.renderNode.element;
+
+ // Turn an element node into a markup
+ function markupFromNode(node) {
+ if (Markup.isValidElement(node)) {
+ let tagName = node.tagName;
+ let attributes = getAttributesArray(node);
+
+ return generateBuilder().generateMarkup(tagName, attributes);
+ }
+ }
+
+ // walk up from the textNode until the rootNode, converting each
+ // parentNode into a markup
+ function collectMarkups(textNode, rootNode) {
+ let markups = [];
+ let currentNode = textNode.parentNode;
+ while (currentNode && currentNode !== rootNode) {
+ let markup = markupFromNode(currentNode);
+ if (markup) {
+ markups.push(markup);
+ }
+
+ currentNode = currentNode.parentNode;
+ }
+ return markups;
+ }
+
+ let seenRenderNodes = [];
+ let previousMarker;
+
+ walkTextNodes(sectionElement, (textNode) => {
+ const text = sanitizeText(textNode.textContent);
+ let markups = collectMarkups(textNode, sectionElement);
+
+ let marker;
+
+ let renderNode = renderTree.elements.get(textNode);
+ if (renderNode) {
+ if (text.length) {
+ marker = renderNode.postNode;
+ marker.value = text;
+ marker.markups = markups;
+ } else {
+ renderNode.scheduleForRemoval();
+ }
+ } else {
+ marker = generateBuilder().generateMarker(markups, text);
+
+ // 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.insertMarkerAfter(marker, previousMarker);
+ section.renderNode.insertAfter(renderNode, previousMarker.renderNode);
+ } else {
+ // insert marker at the beginning of the section
+ section.prependMarker(marker);
+ section.renderNode.insertAfter(renderNode, null);
+ }
+
+ // find the nextMarkerElement, set it on the render node
+ let parentNodeCount = marker.closedMarkups.length;
+ let nextMarkerElement = textNode.parentNode;
+ while (parentNodeCount--) {
+ nextMarkerElement = nextMarkerElement.parentNode;
+ }
+ renderNode.nextMarkerElement = nextMarkerElement;
+ }
+
+ seenRenderNodes.push(renderNode);
+ previousMarker = marker;
+ });
+
+ // schedule any nodes that were not marked as seen
+ let node = section.renderNode.firstChild;
+ while (node) {
+ if (seenRenderNodes.indexOf(node) === -1) {
+ // remove it
+ node.scheduleForRemoval();
+ }
+
+ node = node.nextSibling;
+ }
}
};
diff --git a/src/js/parsers/section.js b/src/js/parsers/section.js
index adfc9d0b3..1e36e6bb8 100644
--- a/src/js/parsers/section.js
+++ b/src/js/parsers/section.js
@@ -12,6 +12,7 @@ import Markup from 'content-kit-editor/models/markup';
import { VALID_MARKUP_TAGNAMES } from 'content-kit-editor/models/markup';
import { getAttributes } from 'content-kit-editor/utils/dom-utils';
import { forEach } from 'content-kit-editor/utils/array-utils';
+import { generateBuilder } from 'content-kit-editor/utils/post-builder';
/**
* parses an element into a section, ignoring any non-markup
@@ -20,10 +21,6 @@ import { forEach } from 'content-kit-editor/utils/array-utils';
*/
export default {
parse(element) {
- if (!this.isSectionElement(element)) {
- element = this.wrapInSectionElement(element);
- }
-
const tagName = this.sectionTagNameFromElement(element);
const section = new MarkupSection(tagName);
const state = {section, markups:[], text:''};
@@ -38,13 +35,11 @@ export default {
state.section.appendMarker(marker);
}
- return section;
- },
+ if (section.markers.length === 0) {
+ section.appendMarker(generateBuilder().generateBlankMarker());
+ }
- wrapInSectionElement(element) {
- const parent = document.createElement(DEFAULT_TAG_NAME);
- parent.appendChild(element);
- return parent;
+ return section;
},
parseNode(node, state) {
@@ -104,7 +99,8 @@ export default {
},
sectionTagNameFromElement(element) {
- let tagName = element.tagName.toLowerCase();
+ let tagName = element.tagName;
+ tagName = tagName && tagName.toLowerCase();
if (VALID_MARKUP_SECTION_TAGNAMES.indexOf(tagName) === -1) { tagName = DEFAULT_TAG_NAME; }
return tagName;
}
diff --git a/src/js/renderers/editor-dom.js b/src/js/renderers/editor-dom.js
index 70e434065..2271b0f0d 100644
--- a/src/js/renderers/editor-dom.js
+++ b/src/js/renderers/editor-dom.js
@@ -3,7 +3,12 @@ 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 { MARKER_TYPE } from "../models/marker";
import { IMAGE_SECTION_TYPE } from "../models/image";
+import { CARD_TYPE } from "../models/card";
+import { clearChildNodes } from '../utils/dom-utils';
+
+export const UNPRINTABLE_CHARACTER = "\u200C";
function createElementFromMarkup(doc, markup) {
var element = doc.createElement(markup.tagName);
@@ -15,39 +20,72 @@ function createElementFromMarkup(doc, markup) {
return element;
}
-function renderMarkupSection(doc, section, markers) {
+// ascends from element upward, returning the last parent node that is not
+// parentElement
+function penultimateParentOf(element, parentElement) {
+ while (parentElement &&
+ element.parentNode !== parentElement &&
+ element.parentElement !== document.body // ensure the while loop stops
+ ) {
+ element = element.parentNode;
+ }
+ return element;
+}
+
+function renderMarkupSection(doc, section) {
var element = doc.createElement(section.tagName);
- var elements = [element];
- var currentElement = element;
- var i, l, j, m, marker, openTypes, closeTypes, text;
- var markup;
- var openedElement;
- for (i=0, l=markers.length;i=0;j--) {
+ markup = openTypes[j];
+ let openedElement = createElementFromMarkup(document, markup);
+ openedElement.appendChild(currentElement);
+ currentElement = openedElement;
+ }
+
+ if (previousRenderNode) {
+ let nextMarkerElement = getNextMarkerElement(previousRenderNode);
+
+ let previousSibling = previousRenderNode.element;
+ let previousSiblingPenultimate = penultimateParentOf(previousSibling, nextMarkerElement);
+ nextMarkerElement.insertBefore(currentElement, previousSiblingPenultimate.nextSibling);
+ } else {
+ element.insertBefore(currentElement, element.firstChild);
+ }
+
+ return textNode;
+}
+
class Visitor {
constructor(cards, unknownCardHandler, options) {
this.cards = cards;
@@ -63,9 +101,9 @@ class Visitor {
visit(renderNode, post.sections);
}
- [MARKUP_SECTION_TYPE](renderNode, section) {
+ [MARKUP_SECTION_TYPE](renderNode, section, visit) {
if (!renderNode.element) {
- let element = renderMarkupSection(window.document, section, section.markers);
+ let element = renderMarkupSection(window.document, section);
if (renderNode.previousSibling) {
let previousElement = renderNode.previousSibling.element;
let nextElement = previousElement.nextSibling;
@@ -78,6 +116,25 @@ class Visitor {
}
renderNode.element = element;
}
+
+ // remove all elements so that we can rerender
+ clearChildNodes(renderNode.element);
+
+ const visitAll = true;
+ visit(renderNode, section.markers, visitAll);
+ }
+
+ [MARKER_TYPE](renderNode, marker) {
+ let parentElement;
+
+ if (renderNode.previousSibling) {
+ parentElement = getNextMarkerElement(renderNode.previousSibling);
+ } else {
+ parentElement = renderNode.parentNode.element;
+ }
+ let textNode = renderMarker(marker, parentElement, renderNode.previousSibling);
+
+ renderNode.element = textNode;
}
[IMAGE_SECTION_TYPE](renderNode, section) {
@@ -102,7 +159,7 @@ class Visitor {
}
}
- card(renderNode, section) {
+ [CARD_TYPE](renderNode, section) {
const card = detect(this.cards, card => card.name === section.name);
const env = { name: section.name };
@@ -134,12 +191,32 @@ let destroyHooks = {
renderNode.element.parentNode.removeChild(renderNode.element);
}
},
+
+ [MARKER_TYPE](renderNode, marker) {
+ // FIXME before we render marker, should delete previous renderNode's element
+ // and up until the next marker element
+
+ let element = renderNode.element;
+ let nextMarkerElement = getNextMarkerElement(renderNode);
+ while (element.parentNode && element.parentNode !== nextMarkerElement) {
+ element = element.parentNode;
+ }
+
+ marker.section.removeMarker(marker);
+
+ if (element.parentNode) {
+ // if no parentNode, the browser already removed this element
+ element.parentNode.removeChild(element);
+ }
+ },
+
[IMAGE_SECTION_TYPE](renderNode, section) {
let post = renderNode.parentNode.postNode;
post.removeSection(section);
renderNode.element.parentNode.removeChild(renderNode.element);
},
- card(renderNode, section) {
+
+ [CARD_TYPE](renderNode, section) {
if (renderNode.cardNode) {
renderNode.cardNode.teardown();
}
@@ -149,6 +226,7 @@ let destroyHooks = {
}
};
+// removes children from parentNode that are scheduled for removal
function removeChildren(parentNode) {
let child = parentNode.firstChild;
while (child) {
@@ -161,45 +239,44 @@ function removeChildren(parentNode) {
}
}
-function lookupNode(renderTree, parentNode, section, previousNode) {
- if (section.renderNode) {
- return section.renderNode;
+// Find an existing render node for the given postNode, or
+// create one, insert it into the tree, and return it
+function lookupNode(renderTree, parentNode, postNode, previousNode) {
+ if (postNode.renderNode) {
+ return postNode.renderNode;
} else {
- let renderNode = new RenderNode(section);
+ let renderNode = new RenderNode(postNode);
renderNode.renderTree = renderTree;
parentNode.insertAfter(renderNode, previousNode);
- section.renderNode = renderNode;
+ postNode.renderNode = renderNode;
return renderNode;
}
}
-function renderInternal(renderTree, visitor) {
- let nodes = [renderTree.node];
- function visit(parentNode, sections) {
+export default class Renderer {
+ constructor(cards, unknownCardHandler, options) {
+ this.visitor = new Visitor(cards, unknownCardHandler, options);
+ this.nodes = [];
+ }
+
+ visit(renderTree, parentNode, postNodes, visitAll=false) {
let previousNode;
- sections.forEach(section => {
- let node = lookupNode(renderTree, parentNode, section, previousNode);
- if (node.isDirty) {
- nodes.push(node);
+ postNodes.forEach(postNode => {
+ let node = lookupNode(renderTree, parentNode, postNode, previousNode);
+ if (node.isDirty || visitAll) {
+ this.nodes.push(node);
}
previousNode = node;
});
}
- let node = nodes.shift();
- while (node) {
- removeChildren(node);
- visitor[node.postNode.type](node, node.postNode, visit);
- node.markClean();
- node = nodes.shift();
- }
-}
-
-export default class Renderer {
- constructor(cards, unknownCardHandler, options) {
- this.visitor = new Visitor(cards, unknownCardHandler, options);
- }
render(renderTree) {
- renderInternal(renderTree, this.visitor);
+ let node = renderTree.node;
+ while (node) {
+ removeChildren(node);
+ this.visitor[node.postNode.type](node, 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 85580d24c..dab59494c 100644
--- a/src/js/renderers/mobiledoc.js
+++ b/src/js/renderers/mobiledoc.js
@@ -4,6 +4,7 @@ import { MARKUP_SECTION_TYPE } from "../models/markup-section";
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';
@@ -19,7 +20,7 @@ let visitor = {
[IMAGE_SECTION_TYPE](node, opcodes) {
opcodes.push(['openImageSection', node.src]);
},
- card(node, opcodes) {
+ [CARD_TYPE](node, opcodes) {
opcodes.push(['openCardSection', node.name, node.payload]);
},
[MARKER_TYPE](node, opcodes) {
diff --git a/src/js/utils/dom-utils.js b/src/js/utils/dom-utils.js
index f29ab688b..f67ec7aa4 100644
--- a/src/js/utils/dom-utils.js
+++ b/src/js/utils/dom-utils.js
@@ -1,3 +1,7 @@
+import { forEach } from './array-utils';
+
+const TEXT_NODE_TYPE = 3;
+
function detectParentNode(element, callback) {
while (element) {
const result = callback(element);
@@ -16,12 +20,58 @@ function detectParentNode(element, callback) {
};
}
+function isTextNode(node) {
+ return node.nodeType === TEXT_NODE_TYPE;
+}
+
+// perform a pre-order tree traversal of the dom, calling `callbackFn(node)`
+// for every node for which `conditionFn(node)` is true
+function walkDOM(topNode, callbackFn=()=>{}, conditionFn=()=>true) {
+ let currentNode = topNode;
+
+ if (conditionFn(currentNode)) {
+ callbackFn(currentNode);
+ }
+
+ currentNode = currentNode.firstChild;
+
+ while (currentNode) {
+ walkDOM(currentNode, callbackFn, conditionFn);
+ currentNode = currentNode.nextSibling;
+ }
+}
+
+function walkTextNodes(topNode, callbackFn=()=>{}) {
+ const conditionFn = (node) => isTextNode(node);
+ walkDOM(topNode, callbackFn, conditionFn);
+}
+
+
function clearChildNodes(element) {
while (element.childNodes.length) {
element.removeChild(element.childNodes[0]);
}
}
+// walks DOWN the dom from node to childNodes, returning the element
+// for which `conditionFn(element)` is true
+function walkDOMUntil(topNode, conditionFn=() => {}) {
+ if (!topNode) { throw new Error('Cannot call walkDOMUntil without a node'); }
+ let stack = [topNode];
+ let currentElement;
+
+ while (stack.length) {
+ currentElement = stack.pop();
+
+ if (conditionFn(currentElement)) {
+ return currentElement;
+ }
+
+ forEach(currentElement.childNodes, (el) => stack.push(el));
+ }
+}
+
+
// see https://github.com/webmodules/node-contains/blob/master/index.js
function containsNode(parentNode, childNode) {
const isSame = () => parentNode === childNode;
@@ -32,33 +82,42 @@ function containsNode(parentNode, childNode) {
return isSame() || isContainedBy();
}
-function forEachChildNode(element, callback) {
- for (let i=0; i result[name] = value);
}
return result;
}
+/**
+ * converts the element's NamedNodeMap of attrs into
+ * an array of key1,value1,key2,value2,...
+ * FIXME should add a whitelist as a second arg
+ */
+function getAttributesArray(element) {
+ let attributes = getAttributes(element);
+ let result = [];
+ Object.keys(attributes).forEach((key) => {
+ result.push(key);
+ result.push(attributes[key]);
+ });
+ return result;
+}
+
export {
detectParentNode,
containsNode,
clearChildNodes,
- forEachChildNode,
- getAttributes
+ getAttributes,
+ getAttributesArray,
+ walkDOMUntil,
+ walkTextNodes
};
diff --git a/src/js/utils/keycodes.js b/src/js/utils/keycodes.js
index 093376771..cbae0de87 100644
--- a/src/js/utils/keycodes.js
+++ b/src/js/utils/keycodes.js
@@ -1,8 +1,8 @@
export default {
LEFT_ARROW: 37,
- BKSP : 8,
+ BACKSPACE : 8,
ENTER : 13,
ESC : 27,
- DEL : 46,
+ DELETE : 46,
M : 77
};
diff --git a/src/js/utils/post-builder.js b/src/js/utils/post-builder.js
index 131a05a57..83344f28e 100644
--- a/src/js/utils/post-builder.js
+++ b/src/js/utils/post-builder.js
@@ -3,6 +3,7 @@ import MarkupSection from "../models/markup-section";
import ImageSection from "../models/image";
import Marker from "../models/marker";
import Markup from "../models/markup";
+import Card from "../models/card";
var builder = {
generatePost() {
@@ -23,13 +24,15 @@ var builder = {
return section;
},
generateCardSection(name, payload={}) {
- const type = 'card';
- return { name, payload, type };
+ return new Card(name, payload);
},
- generateMarker: function(markers, value) {
- return new Marker(value, markers);
+ generateMarker(markups, value) {
+ return new Marker(value, markups);
},
- generateMarkup: function(tagName, attributes) {
+ generateBlankMarker() {
+ return new Marker('__BLANK__');
+ },
+ generateMarkup(tagName, attributes) {
if (attributes) {
// FIXME: This could also be cached
return new Markup(tagName, attributes);
diff --git a/tests/acceptance/editor-commands-test.js b/tests/acceptance/editor-commands-test.js
index b87bc2aac..3abc1cc23 100644
--- a/tests/acceptance/editor-commands-test.js
+++ b/tests/acceptance/editor-commands-test.js
@@ -1,18 +1,28 @@
import { Editor } from 'content-kit-editor';
import Helpers from '../test-helpers';
+import { MOBILEDOC_VERSION } from 'content-kit-editor/renderers/mobiledoc';
const { test, module } = QUnit;
let fixture, editor, editorElement, selectedText;
+const mobiledoc = {
+ version: MOBILEDOC_VERSION,
+ sections: [
+ [],
+ [[
+ 1, 'P', [[[], 0, 'THIS IS A TEST']]
+ ]]
+ ]
+};
+
module('Acceptance: Editor commands', {
beforeEach() {
fixture = document.getElementById('qunit-fixture');
editorElement = document.createElement('div');
editorElement.setAttribute('id', 'editor');
- editorElement.innerHTML = 'THIS IS A TEST';
fixture.appendChild(editorElement);
- editor = new Editor(editorElement);
+ editor = new Editor(editorElement, {mobiledoc});
selectedText = 'IS A';
Helpers.dom.selectText(selectedText, editorElement);
diff --git a/tests/acceptance/editor-sections-test.js b/tests/acceptance/editor-sections-test.js
index 7bbac8a0f..ab550b42e 100644
--- a/tests/acceptance/editor-sections-test.js
+++ b/tests/acceptance/editor-sections-test.js
@@ -1,11 +1,10 @@
import { Editor } from 'content-kit-editor';
import Helpers from '../test-helpers';
import { MOBILEDOC_VERSION } from 'content-kit-editor/renderers/mobiledoc';
+import { UNPRINTABLE_CHARACTER } from 'content-kit-editor/renderers/editor-dom';
const { test, module } = QUnit;
-const newline = '\r\n';
-
let fixture, editor, editorElement;
const mobileDocWith1Section = {
version: MOBILEDOC_VERSION,
@@ -50,6 +49,43 @@ const mobileDocWith3Sections = {
]
};
+const mobileDocWith2Markers = {
+ version: MOBILEDOC_VERSION,
+ sections: [
+ [['b']],
+ [
+ [1, "P", [
+ [[0], 1, "bold"],
+ [[], 0, "plain"]
+ ]]
+ ]
+ ]
+};
+
+const mobileDocWith1Character = {
+ version: MOBILEDOC_VERSION,
+ sections: [
+ [],
+ [
+ [1, "P", [
+ [[], 0, "c"]
+ ]]
+ ]
+ ]
+};
+
+const mobileDocWithNoCharacter = {
+ version: MOBILEDOC_VERSION,
+ sections: [
+ [],
+ [
+ [1, "P", [
+ [[], 0, ""]
+ ]]
+ ]
+ ]
+};
+
module('Acceptance: Editor sections', {
beforeEach() {
fixture = document.getElementById('qunit-fixture');
@@ -59,22 +95,22 @@ module('Acceptance: Editor sections', {
},
afterEach() {
- editor.destroy();
+ if (editor) {
+ editor.destroy();
+ }
}
});
-test('typing inserts section', (assert) => {
+Helpers.skipInPhantom('typing inserts section', (assert) => {
editor = new Editor(editorElement, {mobiledoc: mobileDocWith1Section});
assert.equal($('#editor p').length, 1, 'has 1 paragraph to start');
- const text = 'new section';
-
- Helpers.dom.moveCursorTo(editorElement);
- document.execCommand('insertText', false, text + newline);
+ Helpers.dom.moveCursorTo(editorElement.childNodes[0].childNodes[0], 5);
+ Helpers.dom.triggerKeyEvent(document, 'keydown', Helpers.dom.KEY_CODES.ENTER);
assert.equal($('#editor p').length, 2, 'has 2 paragraphs after typing return');
- assert.hasElement(`#editor p:contains(${text})`, 'has first pargraph with "A"');
- assert.hasElement('#editor p:contains(only section)', 'has correct second paragraph text');
+ assert.hasElement(`#editor p:contains(only)`, 'has correct first pargraph text');
+ assert.hasElement('#editor p:contains(section)', 'has correct second paragraph text');
});
test('deleting across 0 sections merges them', (assert) => {
@@ -110,3 +146,170 @@ test('deleting across 1 section removes it, joins the 2 boundary sections', (ass
assert.hasElement('#editor p:contains(first section)',
'remaining paragraph has correct text');
});
+
+Helpers.skipInPhantom('keystroke of delete removes that character', (assert) => {
+ editor = new Editor(editorElement, {mobiledoc: mobileDocWith3Sections});
+ const getFirstTextNode = () => {
+ return editor.element.
+ firstChild. // section
+ firstChild; // marker
+ };
+ const textNode = getFirstTextNode();
+ Helpers.dom.moveCursorTo(textNode, 1);
+
+ const runDefault = Helpers.dom.triggerKeyEvent(document, 'keydown', Helpers.dom.KEY_CODES.DELETE);
+ if (runDefault) {
+ document.execCommand('delete', false);
+ Helpers.dom.triggerEvent(editor.element, 'input');
+ }
+
+ assert.equal($('#editor p:eq(0)').html(), 'irst section',
+ 'deletes first character');
+
+ const newTextNode = getFirstTextNode();
+ assert.deepEqual(Helpers.dom.getCursorPosition(),
+ {node: newTextNode, offset: 0},
+ 'cursor is at start of new text node');
+});
+
+Helpers.skipInPhantom('keystroke of delete when cursor is at beginning of marker removes character from previous marker', (assert) => {
+ editor = new Editor(editorElement, {mobiledoc: mobileDocWith2Markers});
+ const textNode = editor.element.
+ firstChild. // section
+ childNodes[1]; // plain marker
+
+ assert.ok(!!textNode, 'gets text node');
+ Helpers.dom.moveCursorTo(textNode, 0);
+
+ const runDefault = Helpers.dom.triggerKeyEvent(document, 'keydown', Helpers.dom.KEY_CODES.DELETE);
+ if (runDefault) {
+ document.execCommand('delete', false);
+ Helpers.dom.triggerEvent(editor.element, 'input');
+ }
+
+ assert.equal($('#editor p:eq(0)').html(), 'bol plain',
+ 'deletes last character of previous marker');
+
+ const boldNode = editor.element.firstChild. // section
+ firstChild; // bold marker
+ const boldTextNode = boldNode.firstChild;
+
+ assert.deepEqual(Helpers.dom.getCursorPosition(),
+ {node: boldTextNode, offset: 3},
+ 'cursor moves to end of previous text node');
+});
+
+Helpers.skipInPhantom('keystroke of delete when cursor is after only char in only marker of section removes character', (assert) => {
+ editor = new Editor(editorElement, {mobiledoc: mobileDocWith1Character});
+ const getTextNode = () => editor.element.
+ firstChild. // section
+ firstChild; // c marker
+
+ let textNode = getTextNode();
+ assert.ok(!!textNode, 'gets text node');
+ Helpers.dom.moveCursorTo(textNode, 1);
+
+ const runDefault = Helpers.dom.triggerKeyEvent(document, 'keydown', Helpers.dom.KEY_CODES.DELETE);
+ if (runDefault) {
+ document.execCommand('delete', false);
+ Helpers.dom.triggerEvent(editor.element, 'input');
+ }
+
+ assert.equal($('#editor p:eq(0)')[0].textContent, UNPRINTABLE_CHARACTER,
+ 'deletes only character');
+
+ textNode = getTextNode();
+ assert.deepEqual(Helpers.dom.getCursorPosition(),
+ {node: textNode, offset: 0},
+ 'cursor moves to start of empty text node');
+});
+
+Helpers.skipInPhantom('keystroke of character results in unprintable being removed', (assert) => {
+ editor = new Editor(editorElement, {mobiledoc: mobileDocWithNoCharacter});
+ const getTextNode = () => editor.element.
+ firstChild. // section
+ firstChild; // marker
+
+ let textNode = getTextNode();
+ assert.ok(!!textNode, 'gets text node');
+ Helpers.dom.moveCursorTo(textNode, 1);
+
+ const runDefault = Helpers.dom.triggerKeyEvent(document, 'keydown', Helpers.dom.KEY_CODES.M);
+ if (runDefault) {
+ document.execCommand('insertText', false, 'm');
+ Helpers.dom.triggerEvent(editor.element, 'input');
+ }
+
+ textNode = getTextNode();
+ assert.equal(textNode.textContent, 'm',
+ 'adds character');
+
+ assert.equal(textNode.textContent.length, 1);
+
+ assert.deepEqual(Helpers.dom.getCursorPosition(),
+ {node: textNode, offset: 1},
+ 'cursor moves to end of m text node');
+});
+
+Helpers.skipInPhantom('keystroke of delete at start of section joins with previous section', (assert) => {
+ editor = new Editor(editorElement, {mobiledoc: mobileDocWith2Sections});
+
+ let secondSectionTextNode = editor.element.childNodes[1].firstChild;
+
+ assert.equal(secondSectionTextNode.textContent, 'second section',
+ 'finds section section text node');
+
+ Helpers.dom.moveCursorTo(secondSectionTextNode, 0);
+
+ const runDefault = Helpers.dom.triggerKeyEvent(document, 'keydown',
+ Helpers.dom.KEY_CODES.DELETE);
+ if (runDefault) {
+ document.execCommand('delete', false);
+ Helpers.dom.triggerEvent(editor.element, 'input');
+ }
+
+ assert.equal(editor.element.childNodes.length, 1, 'only 1 section remaining');
+
+ let secondSectionNode = editor.element.firstChild;
+ secondSectionTextNode = secondSectionNode.firstChild;
+ assert.equal(secondSectionNode.textContent,
+ 'first sectionsecond section',
+ 'joins two sections');
+
+ assert.deepEqual(Helpers.dom.getCursorPosition(),
+ {node: secondSectionTextNode,
+ offset: secondSectionTextNode.textContent.length},
+ 'cursor moves to end of first section');
+});
+
+
+Helpers.skipInPhantom('keystroke of delete at start of first section does nothing', (assert) => {
+ editor = new Editor(editorElement, {mobiledoc: mobileDocWith2Sections});
+
+ let firstSectionTextNode = editor.element.childNodes[0].firstChild;
+
+ assert.equal(firstSectionTextNode.textContent, 'first section',
+ 'finds first section text node');
+
+ Helpers.dom.moveCursorTo(firstSectionTextNode, 0);
+
+ const runDefault = Helpers.dom.triggerKeyEvent(document, 'keydown',
+ Helpers.dom.KEY_CODES.DELETE);
+ if (runDefault) {
+ document.execCommand('delete', false);
+ Helpers.dom.triggerEvent(editor.element, 'input');
+ }
+
+ assert.equal(editor.element.childNodes.length, 2, 'still 2 sections');
+ firstSectionTextNode = editor.element.childNodes[0].firstChild;
+ assert.equal(firstSectionTextNode.textContent,
+ 'first section',
+ 'first section still has same text content');
+
+ assert.deepEqual(Helpers.dom.getCursorPosition(),
+ {node: firstSectionTextNode,
+ offset: 0},
+ 'cursor stays at start of first section');
+});
+
+// test: deleting at start of section when previous section is a non-markup section
diff --git a/tests/helpers/dom.js b/tests/helpers/dom.js
index 9c9b8ce4f..1e44c1fe7 100644
--- a/tests/helpers/dom.js
+++ b/tests/helpers/dom.js
@@ -1,26 +1,9 @@
const TEXT_NODE = 3;
import { clearSelection } from 'content-kit-editor/utils/selection-utils';
+import { walkDOMUntil } from 'content-kit-editor/utils/dom-utils';
import KEY_CODES from 'content-kit-editor/utils/keycodes';
-function walkDOMUntil(topNode, conditionFn=() => {}) {
- if (!topNode) { throw new Error('Cannot call walkDOMUntil without a node'); }
- let stack = [topNode];
- let currentElement;
-
- while (stack.length) {
- currentElement = stack.pop();
-
- if (conditionFn(currentElement)) {
- return currentElement;
- }
-
- for (let i=0; i < currentElement.childNodes.length; i++) {
- stack.push(currentElement.childNodes[i]);
- }
- }
-}
-
function selectRange(startNode, startOffset, endNode, endOffset) {
clearSelection();
@@ -63,7 +46,7 @@ function triggerEvent(node, eventType) {
let clickEvent = document.createEvent('MouseEvents');
clickEvent.initEvent(eventType, true, true);
- node.dispatchEvent(clickEvent);
+ return node.dispatchEvent(clickEvent);
}
function createKeyEvent(eventType, keyCode) {
@@ -93,7 +76,7 @@ function createKeyEvent(eventType, keyCode) {
function triggerKeyEvent(node, eventType, keyCode=KEY_CODES.ENTER) {
let oEvent = createKeyEvent(eventType, keyCode);
- node.dispatchEvent(oEvent);
+ return node.dispatchEvent(oEvent);
}
function _buildDOM(tagName, attributes={}, children=[]) {
@@ -121,11 +104,22 @@ function makeDOM(tree) {
return tree(_buildDOM);
}
+// returns the node and the offset that the cursor is on
+function getCursorPosition() {
+ const selection = window.getSelection();
+ return {
+ node: selection.anchorNode,
+ offset: selection.anchorOffset
+ };
+}
+
export default {
moveCursorTo,
selectText,
clearSelection,
triggerEvent,
triggerKeyEvent,
- makeDOM
+ makeDOM,
+ KEY_CODES,
+ getCursorPosition
};
diff --git a/tests/unit/editor/editor-destroy-test.js b/tests/unit/editor/editor-destroy-test.js
index f671c1a56..403ef33b4 100644
--- a/tests/unit/editor/editor-destroy-test.js
+++ b/tests/unit/editor/editor-destroy-test.js
@@ -1,18 +1,29 @@
const { module, test } = window.QUnit;
import Helpers from '../../test-helpers';
+import { MOBILEDOC_VERSION } from 'content-kit-editor/renderers/mobiledoc';
import { Editor } from 'content-kit-editor';
let editor;
let editorElement;
+const mobiledoc = {
+ version: MOBILEDOC_VERSION,
+ sections: [
+ [],
+ [[
+ 1, 'P', [[[], 0, 'HELLO']]
+ ]]
+ ]
+};
+
+
module('Unit: Editor #destroy', {
beforeEach() {
let fixture = $('#qunit-fixture')[0];
editorElement = document.createElement('div');
- editorElement.innerHTML = 'HELLO';
fixture.appendChild(editorElement);
- editor = new Editor(editorElement);
+ editor = new Editor(editorElement, {mobiledoc});
},
afterEach() {
if (editor) {
diff --git a/tests/unit/editor/editor-events-test.js b/tests/unit/editor/editor-events-test.js
index 948486d03..a6b02aedb 100644
--- a/tests/unit/editor/editor-events-test.js
+++ b/tests/unit/editor/editor-events-test.js
@@ -1,18 +1,28 @@
const { module, test } = QUnit;
import Helpers from '../../test-helpers';
+import { MOBILEDOC_VERSION } from 'content-kit-editor/renderers/mobiledoc';
import { Editor } from 'content-kit-editor';
let editor, editorElement;
let triggered = [];
+const mobiledoc = {
+ version: MOBILEDOC_VERSION,
+ sections: [
+ [],
+ [[
+ 1, 'P', [[[], 0, 'this is the editor']]
+ ]]
+ ]
+};
+
module('Unit: Editor: events', {
beforeEach() {
editorElement = document.createElement('div');
- editorElement.innerHTML = 'this is the editor';
document.getElementById('qunit-fixture').appendChild(editorElement);
- editor = new Editor(editorElement);
+ editor = new Editor(editorElement, {mobiledoc});
editor.trigger = (name) => triggered.push(name);
},
diff --git a/tests/unit/models/section-test.js b/tests/unit/models/section-test.js
index 149f1f60d..9fc949d03 100644
--- a/tests/unit/models/section-test.js
+++ b/tests/unit/models/section-test.js
@@ -34,17 +34,18 @@ test('#markerContaining finds the marker at the given offset when 2 markers', (a
assert.equal(s.markerContaining(0), m1,
'first marker is always found at offset 0');
- assert.equal(s.markerContaining(m1.length + m2.length), m2,
- 'last marker is always found at offset === length');
- assert.equal(s.markerContaining(m1.length + m2.length + 1), m2,
- 'last marker is always found at offset > length');
+ assert.equal(s.markerContaining(m1.length + m2.length, false), m2,
+ 'last marker is found at offset === length when right-inclusive');
+ assert.ok(!s.markerContaining(m1.length + m2.length + 1),
+ 'when offset > length && left-inclusive, no marker is found');
+ assert.ok(!s.markerContaining(m1.length + m2.length + 1, false),
+ 'when offset > length && right-inclusive, no marker is found');
for (let i=1; i length');
+ assert.ok(!s.markerContaining(markerLength),
+ 'last marker is undefined at offset === length (left-inclusive)');
+ assert.equal(s.markerContaining(markerLength, false), m3,
+ 'last marker is found at offset === length (right-inclusive)');
+ assert.ok(!s.markerContaining(markerLength + 1),
+ 'no marker is found at offset > length');
for (let i=1; i {
- let element = Helpers.dom.makeDOM(t =>
- t('div', {}, [t.text('some text')])
- );
-
- const post = PostParser.parse(element);
- assert.ok(post, 'gets post');
- assert.equal(post.sections.length, 1, 'has 1 section');
-
- const s1 = post.sections[0];
- assert.equal(s1.markers.length, 1, 's1 has 1 marker');
- assert.equal(s1.markers[0].value, 'some text', 'has text');
-});
-
test('#parse can parse a section element', (assert) => {
let element = Helpers.dom.makeDOM(t =>
t('div', {}, [
@@ -43,7 +29,9 @@ test('#parse can parse multiple elements', (assert) => {
t('p', {}, [
t.text('some text')
]),
- t.text('some other text')
+ t('p', {}, [
+ t.text('some other text')
+ ])
])
);
diff --git a/tests/unit/parsers/section-test.js b/tests/unit/parsers/section-test.js
index c1ce21416..6f07d022d 100644
--- a/tests/unit/parsers/section-test.js
+++ b/tests/unit/parsers/section-test.js
@@ -104,16 +104,6 @@ test('#parse joins contiguous text nodes separated by non-markup elements', (ass
assert.equal(m1.value, 'span 1span 2');
});
-test('#parse parses a single text node', (assert) => {
- let element = Helpers.dom.makeDOM(h =>
- h.text('raw text')
- );
- const section = SectionParser.parse(element);
- assert.equal(section.tagName, 'p');
- assert.equal(section.markers.length, 1, 'has 1 marker');
- assert.equal(section.markers[0].value, 'raw text');
-});
-
// test: a section can parse dom
// test: a section can clear a range:
diff --git a/tests/unit/renderers/editor-dom-test.js b/tests/unit/renderers/editor-dom-test.js
index 651d38a46..88274ab06 100644
--- a/tests/unit/renderers/editor-dom-test.js
+++ b/tests/unit/renderers/editor-dom-test.js
@@ -219,6 +219,179 @@ test('renders a card section into a non-contenteditable element', (assert) => {
assert.equal(element.contentEditable, 'false', 'is not contenteditable');
});
+/*
+ * renderTree:
+ *
+ * post
+ * |
+ * section
+ * |
+ * |----------------|
+ * | |
+ * marker1 [b] marker2 []
+ * | |
+ *
+ *
+ * add "b" markup to marker2, new tree should be:
+ *
+ * post
+ * |
+ * section
+ * |
+ * |
+ * |
+ * marker1 [b]
+ * |
+ * +
+ */
+
+test('rerender a marker after adding a markup to it', (assert) => {
+ const post = builder.generatePost();
+ const section = builder.generateMarkupSection();
+ const bMarkup = builder.generateMarkup('B');
+ const marker1 = builder.generateMarker([
+ bMarkup
+ ], 'text1');
+ const marker2 = builder.generateMarker([], 'text2');
+
+ section.appendMarker(marker1);
+ section.appendMarker(marker2);
+ post.appendSection(section);
+
+ let node = new RenderNode(post);
+ let renderTree = new RenderTree(node);
+ node.renderTree = renderTree;
+ render(renderTree);
+
+ assert.equal(node.element.innerHTML,
+ 'text1 text2
');
+
+ marker2.addMarkup(bMarkup);
+ marker2.renderNode.markDirty();
+
+ // rerender
+ render(renderTree);
+
+ assert.equal(node.element.innerHTML,
+ 'text1text2
');
+});
+
+test('rerender a marker after removing a markup from it', (assert) => {
+ const post = builder.generatePost();
+ const section = builder.generateMarkupSection();
+ const bMarkup = builder.generateMarkup('B');
+ const marker1 = builder.generateMarker([], 'text1');
+ const marker2 = builder.generateMarker([bMarkup], 'text2');
+
+ section.appendMarker(marker1);
+ section.appendMarker(marker2);
+ post.appendSection(section);
+
+ let node = new RenderNode(post);
+ let renderTree = new RenderTree(node);
+ node.renderTree = renderTree;
+ render(renderTree);
+
+ assert.equal(node.element.innerHTML,
+ 'text1text2
');
+
+ marker2.removeMarkup(bMarkup);
+ marker2.renderNode.markDirty();
+
+ // rerender
+ render(renderTree);
+
+ assert.equal(node.element.innerHTML,
+ 'text1text2
');
+});
+
+test('rerender a marker after removing a markup from it (when changed marker is first marker)', (assert) => {
+ const post = builder.generatePost();
+ const section = builder.generateMarkupSection();
+ const bMarkup = builder.generateMarkup('B');
+ const marker1 = builder.generateMarker([bMarkup], 'text1');
+ const marker2 = builder.generateMarker([], 'text2');
+
+ section.appendMarker(marker1);
+ section.appendMarker(marker2);
+ post.appendSection(section);
+
+ let node = new RenderNode(post);
+ let renderTree = new RenderTree(node);
+ node.renderTree = renderTree;
+ render(renderTree);
+
+ assert.equal(node.element.innerHTML,
+ 'text1 text2
');
+
+ marker1.removeMarkup(bMarkup);
+ marker1.renderNode.markDirty();
+
+ // rerender
+ render(renderTree);
+
+ assert.equal(node.element.innerHTML,
+ 'text1text2
');
+});
+
+test('rerender a marker after removing a markup from it (when both markers have same markup)', (assert) => {
+ const post = builder.generatePost();
+ const section = builder.generateMarkupSection();
+ const bMarkup = builder.generateMarkup('B');
+ const marker1 = builder.generateMarker([bMarkup], 'text1');
+ const marker2 = builder.generateMarker([bMarkup], 'text2');
+
+ section.appendMarker(marker1);
+ section.appendMarker(marker2);
+ post.appendSection(section);
+
+ let node = new RenderNode(post);
+ let renderTree = new RenderTree(node);
+ node.renderTree = renderTree;
+ render(renderTree);
+
+ assert.equal(node.element.innerHTML,
+ 'text1text2
');
+
+ marker1.removeMarkup(bMarkup);
+ marker1.renderNode.markDirty();
+
+ // rerender
+ render(renderTree);
+
+ assert.equal(node.element.innerHTML,
+ 'text1text2
');
+});
+
+test('rerender a marker after removing a markup from it (when both markers have same markup)', (assert) => {
+ const post = builder.generatePost();
+ const section = builder.generateMarkupSection();
+ const bMarkup = builder.generateMarkup('B');
+ const marker1 = builder.generateMarker([bMarkup], 'text1');
+ const marker2 = builder.generateMarker([bMarkup], 'text2');
+
+ section.appendMarker(marker1);
+ section.appendMarker(marker2);
+ post.appendSection(section);
+
+ let node = new RenderNode(post);
+ let renderTree = new RenderTree(node);
+ node.renderTree = renderTree;
+ render(renderTree);
+
+ assert.equal(node.element.innerHTML,
+ 'text1text2
');
+
+ marker1.removeMarkup(bMarkup);
+ marker1.renderNode.markDirty();
+
+ // rerender
+ render(renderTree);
+
+ assert.equal(node.element.innerHTML,
+ 'text1text2
');
+});
+
/*
test("It renders a renderTree with rendered dirty section", (assert) => {