diff --git a/demo/demo.js b/demo/demo.js index de8b97c9b..1bfd06272 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -202,16 +202,21 @@ var ContentKitDemo = exports.ContentKitDemo = { } } - function addPipeBetweenAdjacentTextNodes(textNode) { + function markAdjacentTextNodes(textNode) { + var boxChar = '\u2591', + emptySquareChar = '\u25A2', + invisibleChar = '\u200C'; var nextSibling = textNode.nextSibling; if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE) { - textNode.textContent = textNode.textContent + '|'; + textNode.textContent = textNode.textContent + boxChar; } + textNode.textContent = textNode.textContent.replace(new RegExp(invisibleChar, 'g'), + emptySquareChar); } var deep = true; var cloned = node.cloneNode(deep); - convertTextNodes(cloned, addPipeBetweenAdjacentTextNodes); + convertTextNodes(cloned, markAdjacentTextNodes); return displayHTML(cloned.innerHTML); }; diff --git a/demo/index.html b/demo/index.html index c8bc75d4f..6f993a2a4 100644 --- a/demo/index.html +++ b/demo/index.html @@ -76,6 +76,7 @@

rendered mobiledoc (dom)


innerHTML of editor surface

+

With special chars to mark text node boundaries and invisible characters.

diff --git a/notes b/notes new file mode 100644 index 000000000..2ba002ca6 --- /dev/null +++ b/notes @@ -0,0 +1,59 @@ +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..be4a9ebf2 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -17,14 +17,12 @@ 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 PostParser from '../parsers/post'; import Renderer 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,81 @@ class Editor { this._renderer.render(this._renderTree); } + handleDeletion(event) { + let { + leftRenderNode, + leftOffset + } = this.cursor.offsets; + + // need to handle these cases: + // when cursor is: + // * in the middle of a marker + // * offset is 0 and there is a previous marker + // * offset is 0 and there is no previous marker + + const currentMarker = leftRenderNode.postNode; + if (leftOffset !== 0) { + currentMarker.deleteValueAtOffset(leftOffset-1); + leftRenderNode.markDirty(); + } else { + let previousMarker = currentMarker.previousSibling; + if (previousMarker) { + let markerLength = previousMarker.length; + previousMarker.deleteValueAtOffset(markerLength - 1); + } + } + + this.rerender(); + + this.cursor.moveToNode(leftRenderNode.element, leftOffset-1); + + 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 +373,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 +406,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 +426,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,15 +495,6 @@ 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); @@ -438,7 +522,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 +541,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/cursor.js b/src/js/models/cursor.js new file mode 100644 index 000000000..7dc138dd4 --- /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) { + let r = document.createRange(); + r.setStart(node, offset); + r.setEnd(node, offset); + 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..247b16534 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= 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 +93,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..229c0dbcd 100644 --- a/src/js/models/render-node.js +++ b/src/js/models/render-node.js @@ -11,9 +11,15 @@ export default class RenderNode { } 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..cd68b1b40 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,104 @@ 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) { + marker = renderNode.postNode; + marker.value = text; + marker.markups = markups; + } 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..4d5a654b9 100644 --- a/src/js/renderers/editor-dom.js +++ b/src/js/renderers/editor-dom.js @@ -3,8 +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 { MARKER_TYPE } from "../models/marker"; import { IMAGE_SECTION_TYPE } from "../models/image"; +export const UNPRINTABLE_CHARACTER = "\u200C"; + function createElementFromMarkup(doc, markup) { var element = doc.createElement(markup.tagName); if (markup.attributes) { @@ -15,39 +18,69 @@ function createElementFromMarkup(doc, markup) { return element; } -function renderMarkupSection(doc, section, markers) { +function penultimateParentOf(element, parentElement) { + while (parentElement && + element.parentNode !== parentElement && + element.parentElement !== document.body) { + 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 +96,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 +111,30 @@ class Visitor { } renderNode.element = element; } + const visitAll = true; + visit(renderNode, section.markers, visitAll); + } + + [MARKER_TYPE](renderNode, marker) { + let parentElement; + + // delete previously existing element + if (renderNode.element) { + const elementForRemoval = penultimateParentOf(renderNode.element, renderNode.attachedTo); + if (elementForRemoval.parentNode) { + elementForRemoval.parentNode.removeChild(elementForRemoval); + } + } + + if (renderNode.previousSibling) { + parentElement = getNextMarkerElement(renderNode.previousSibling); + } else { + parentElement = renderNode.parentNode.element; + } + let textNode = renderMarker(marker, parentElement, renderNode.previousSibling); + + renderNode.attachedTo = parentElement; + renderNode.element = textNode; } [IMAGE_SECTION_TYPE](renderNode, section) { @@ -134,11 +191,31 @@ 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) { if (renderNode.cardNode) { renderNode.cardNode.teardown(); @@ -161,25 +238,27 @@ 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) { + function visit(parentNode, postNodes, visitAll=false) { let previousNode; - sections.forEach(section => { - let node = lookupNode(renderTree, parentNode, section, previousNode); - if (node.isDirty) { + postNodes.forEach(postNode => { + let node = lookupNode(renderTree, parentNode, postNode, previousNode); + if (node.isDirty || visitAll) { nodes.push(node); } previousNode = node; 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..3d2478ad8 100644 --- a/src/js/utils/post-builder.js +++ b/src/js/utils/post-builder.js @@ -26,10 +26,13 @@ var builder = { const type = 'card'; return { name, payload, type }; }, - 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..a11ea3f25 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,31 @@ 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"] + ]] + ] + ] +}; + module('Acceptance: Editor sections', { beforeEach() { fixture = document.getElementById('qunit-fixture'); @@ -59,22 +83,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 +134,80 @@ 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(), 'bolplain', + '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'); +}); 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..e501d6e35 100644 --- a/tests/unit/renderers/editor-dom-test.js +++ b/tests/unit/renderers/editor-dom-test.js @@ -219,6 +219,150 @@ 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, + '

text1text2

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

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) => {