From 99824ba18e00bb001aceaf9add419f374c49c94d Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Tue, 28 Jul 2015 12:11:34 -0400 Subject: [PATCH 1/5] Handle newline semantically, use special chars to denote text nodes and unprintable chars in editor HTML * move cursor to new section after hitting enter * Select the first marker el in a new section rather than the section el * Append blank marker to empty section, only create markup from valid tag * Do not modify/wrap element in section element in Section parser --- demo/demo.js | 11 +- demo/index.html | 1 + notes | 59 +++++++ src/js/commands/card.js | 11 -- src/js/commands/oembed.js | 8 - src/js/editor/editor.js | 213 +++++++++++++++-------- src/js/models/cursor.js | 180 +++++++++++++++++++ src/js/models/marker.js | 22 +++ src/js/models/markup-section.js | 45 ++++- src/js/models/markup.js | 5 + src/js/models/render-node.js | 6 + src/js/parsers/post.js | 110 +++++++++++- src/js/parsers/section.js | 18 +- src/js/renderers/editor-dom.js | 151 ++++++++++++---- src/js/utils/dom-utils.js | 83 +++++++-- src/js/utils/keycodes.js | 4 +- src/js/utils/post-builder.js | 9 +- tests/acceptance/editor-commands-test.js | 14 +- tests/acceptance/editor-sections-test.js | 121 +++++++++++-- tests/helpers/dom.js | 36 ++-- tests/unit/editor/editor-destroy-test.js | 15 +- tests/unit/editor/editor-events-test.js | 14 +- tests/unit/models/section-test.js | 34 ++-- tests/unit/parsers/post-test.js | 18 +- tests/unit/parsers/section-test.js | 10 -- tests/unit/renderers/editor-dom-test.js | 144 +++++++++++++++ 26 files changed, 1107 insertions(+), 235 deletions(-) create mode 100644 notes create mode 100644 src/js/models/cursor.js 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) => { From be005081152c54a83d5c23b6279ac11b51fa9dbb Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Thu, 30 Jul 2015 15:12:46 -0400 Subject: [PATCH 2/5] change selfie demo to use `src` --- demo/demo.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/demo/demo.js b/demo/demo.js index 1bfd06272..d1ad53591 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -15,14 +15,13 @@ var selfieCard = { setup: function(element, options, env, payload) { removeChildren(element); - if (payload.imageSrc) { + if (payload.src) { element.appendChild( $('' + '
' + - '
' + + '
' + '
You look nice today.
' + (env.edit ? "
" : "") + - '
' + '
' + '')[0] ); @@ -69,8 +68,8 @@ var selfieCard = { $('#snap').click(function() { context.drawImage(video, 0, 0, 160, 120); - var imageSrc = canvas.toDataURL('image/png'); - env.save({imageSrc: imageSrc}); + var src = canvas.toDataURL('image/png'); + env.save({src: src}); }); }, errBack); } From 5febfc4a24ac53127139fea45a759641cea22df6 Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Thu, 30 Jul 2015 15:13:28 -0400 Subject: [PATCH 3/5] Handle deletion (without selection) semantically --- notes | 4 + src/js/editor/editor.js | 105 ++++++++++++++++++++-- src/js/models/card.js | 9 ++ src/js/models/cursor.js | 4 +- src/js/models/markup-section.js | 5 ++ src/js/models/render-node.js | 1 + src/js/parsers/post.js | 10 ++- src/js/renderers/editor-dom.js | 60 +++++++------ src/js/renderers/mobiledoc.js | 3 +- src/js/utils/post-builder.js | 4 +- tests/acceptance/editor-sections-test.js | 106 ++++++++++++++++++++++- tests/unit/renderers/editor-dom-test.js | 29 +++++++ 12 files changed, 291 insertions(+), 49 deletions(-) create mode 100644 src/js/models/card.js diff --git a/notes b/notes index 2ba002ca6..2e925b053 100644 --- a/notes +++ b/notes @@ -1,3 +1,7 @@ +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 diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index be4a9ebf2..c5df579fe 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -23,7 +23,7 @@ import EventEmitter from '../utils/event-emitter'; import MobiledocParser from "../parsers/mobiledoc"; import PostParser from '../parsers/post'; -import Renderer from 'content-kit-editor/renderers/editor-dom'; +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'; @@ -279,6 +279,7 @@ class Editor { this._renderer.render(this._renderTree); } + // FIXME ensure we handle deletion when there is a selection handleDeletion(event) { let { leftRenderNode, @@ -287,25 +288,74 @@ class Editor { // 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 + // * 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); - leftRenderNode.markDirty(); + 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) { + 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(leftRenderNode.element, leftOffset-1); + this.cursor.moveToNode(nextCursorMarker.renderNode.element, + nextCursorOffset); this.trigger('update'); event.preventDefault(); @@ -440,7 +490,7 @@ class Editor { * 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'); @@ -501,8 +551,47 @@ class Editor { } }); + 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() { 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 index 7dc138dd4..870ba68cc 100644 --- a/src/js/models/cursor.js +++ b/src/js/models/cursor.js @@ -164,10 +164,10 @@ const Cursor = class Cursor { selection.addRange(r); } - moveToNode(node, offset=0) { + moveToNode(node, offset=0, endNode=node, endOffset=offset) { let r = document.createRange(); r.setStart(node, offset); - r.setEnd(node, offset); + r.setEnd(endNode, endOffset); const selection = this.selection; if (selection.rangeCount > 0) { selection.removeAllRanges(); diff --git a/src/js/models/markup-section.js b/src/js/models/markup-section.js index 247b16534..a9efbd0bd 100644 --- a/src/js/models/markup-section.js +++ b/src/js/models/markup-section.js @@ -76,6 +76,11 @@ export default class Section { ]; } + // mutates this by appending the other section's (cloned) markers to it + join(otherSection) { + otherSection.markers.forEach(m => this.appendMarker(m.clone())); + } + /** * A marker contains this offset if: * * The offset is between the marker's start and end diff --git a/src/js/models/render-node.js b/src/js/models/render-node.js index 229c0dbcd..da4fa7ae2 100644 --- a/src/js/models/render-node.js +++ b/src/js/models/render-node.js @@ -6,6 +6,7 @@ export default class RenderNode { this.postNode = postNode; this.firstChild = null; + this.lastChild = null; this.nextSibling = null; this.previousSibling = null; } diff --git a/src/js/parsers/post.js b/src/js/parsers/post.js index cd68b1b40..631fb94c6 100644 --- a/src/js/parsers/post.js +++ b/src/js/parsers/post.js @@ -77,9 +77,13 @@ export default { let renderNode = renderTree.elements.get(textNode); if (renderNode) { - marker = renderNode.postNode; - marker.value = text; - marker.markups = markups; + if (text.length) { + marker = renderNode.postNode; + marker.value = text; + marker.markups = markups; + } else { + renderNode.scheduleForRemoval(); + } } else { marker = generateBuilder().generateMarker(markups, text); diff --git a/src/js/renderers/editor-dom.js b/src/js/renderers/editor-dom.js index 4d5a654b9..2271b0f0d 100644 --- a/src/js/renderers/editor-dom.js +++ b/src/js/renderers/editor-dom.js @@ -5,6 +5,8 @@ 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"; @@ -18,10 +20,13 @@ function createElementFromMarkup(doc, markup) { return element; } +// 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) { + element.parentElement !== document.body // ensure the while loop stops + ) { element = element.parentNode; } return element; @@ -37,7 +42,7 @@ function isEmptyText(text) { return text.trim() === ''; } -// pass in a renderNode's previousSiblin +// pass in a renderNode's previousSibling function getNextMarkerElement(renderNode) { let element = renderNode.element.parentNode; let closedCount = renderNode.postNode.closedMarkups.length; @@ -111,6 +116,10 @@ class Visitor { } renderNode.element = element; } + + // remove all elements so that we can rerender + clearChildNodes(renderNode.element); + const visitAll = true; visit(renderNode, section.markers, visitAll); } @@ -118,14 +127,6 @@ class Visitor { [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 { @@ -133,7 +134,6 @@ class Visitor { } let textNode = renderMarker(marker, parentElement, renderNode.previousSibling); - renderNode.attachedTo = parentElement; renderNode.element = textNode; } @@ -159,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 }; @@ -216,7 +216,7 @@ let destroyHooks = { renderNode.element.parentNode.removeChild(renderNode.element); }, - card(renderNode, section) { + [CARD_TYPE](renderNode, section) { if (renderNode.cardNode) { renderNode.cardNode.teardown(); } @@ -226,6 +226,7 @@ let destroyHooks = { } }; +// removes children from parentNode that are scheduled for removal function removeChildren(parentNode) { let child = parentNode.firstChild; while (child) { @@ -252,33 +253,30 @@ function lookupNode(renderTree, parentNode, postNode, previousNode) { } } -function renderInternal(renderTree, visitor) { - let nodes = [renderTree.node]; - function visit(parentNode, postNodes, visitAll=false) { +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; postNodes.forEach(postNode => { let node = lookupNode(renderTree, parentNode, postNode, previousNode); if (node.isDirty || visitAll) { - nodes.push(node); + 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/post-builder.js b/src/js/utils/post-builder.js index 3d2478ad8..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,8 +24,7 @@ var builder = { return section; }, generateCardSection(name, payload={}) { - const type = 'card'; - return { name, payload, type }; + return new Card(name, payload); }, generateMarker(markups, value) { return new Marker(value, markups); diff --git a/tests/acceptance/editor-sections-test.js b/tests/acceptance/editor-sections-test.js index a11ea3f25..ab550b42e 100644 --- a/tests/acceptance/editor-sections-test.js +++ b/tests/acceptance/editor-sections-test.js @@ -74,6 +74,18 @@ const mobileDocWith1Character = { ] }; +const mobileDocWithNoCharacter = { + version: MOBILEDOC_VERSION, + sections: [ + [], + [ + [1, "P", [ + [[], 0, ""] + ]] + ] + ] +}; + module('Acceptance: Editor sections', { beforeEach() { fixture = document.getElementById('qunit-fixture'); @@ -165,7 +177,7 @@ Helpers.skipInPhantom('keystroke of delete when cursor is at beginning of marker const textNode = editor.element. firstChild. // section childNodes[1]; // plain marker - + assert.ok(!!textNode, 'gets text node'); Helpers.dom.moveCursorTo(textNode, 0); @@ -192,7 +204,7 @@ Helpers.skipInPhantom('keystroke of delete when cursor is after only char in onl const getTextNode = () => editor.element. firstChild. // section firstChild; // c marker - + let textNode = getTextNode(); assert.ok(!!textNode, 'gets text node'); Helpers.dom.moveCursorTo(textNode, 1); @@ -211,3 +223,93 @@ Helpers.skipInPhantom('keystroke of delete when cursor is after only char in onl {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/unit/renderers/editor-dom-test.js b/tests/unit/renderers/editor-dom-test.js index e501d6e35..88274ab06 100644 --- a/tests/unit/renderers/editor-dom-test.js +++ b/tests/unit/renderers/editor-dom-test.js @@ -363,6 +363,35 @@ test('rerender a marker after removing a markup from it (when both markers have '

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) => { From 1fa57e697ec5c923ba9a379cac4a9bc83de1a73f Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Thu, 30 Jul 2015 18:43:07 -0400 Subject: [PATCH 4/5] fix safari bug in demo.js --- demo/demo.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/demo/demo.js b/demo/demo.js index d1ad53591..83cc2f66f 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -360,22 +360,6 @@ var sampleMobiledocs = { ] }, - mobileDocWithAttributeMarker: { - version: MOBILEDOC_VERSION, - sections: [ - [['A', ['href', 'http://github.com/bustlelabs/content-kit-editor']]], - [ - [1, "H2", [ - [[], 0, "headline h2"] - ]], - [1, "P", [ - [[], 0, "see it "], - [[0], 1, "on github."] - ]] - ] - ] - }, - mobileDocWithSimpleCard: { version: MOBILEDOC_VERSION, sections: [ From f49d483520c91d2e6110b9022a1cc32c987ae255 Mon Sep 17 00:00:00 2001 From: Matthew Beale Date: Fri, 31 Jul 2015 11:50:28 -0400 Subject: [PATCH 5/5] Clean up demo --- demo/demo.css | 97 +++++++++++++++++++++++++++++++++++++++------- demo/demo.js | 20 +++++----- demo/index.html | 100 ++++++++++++++++++------------------------------ 3 files changed, 133 insertions(+), 84 deletions(-) diff --git a/demo/demo.css b/demo/demo.css index aba5523ff..b223b30df 100644 --- a/demo/demo.css +++ b/demo/demo.css @@ -4,31 +4,82 @@ box-sizing: border-box; } -body { +html, body { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - color: #454545; + color: #121212; margin: 0; - padding: 0; - background-color: #EFEFEF; + padding: 1.2rem; + background-color: #F3F3F3; + font-size: 1.1rem; + line-height: 1.4; + height: 100%; + width: 100%; } @media only screen and (max-width: 767px) { body { - font-size: 0.88em; + font-size: 0.79rem; } } +h1, h2, h3, h4, h5 { + font-family: "Merriweather Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + margin: 0.2rem 0 0.2rem; +} + +h1 { + font-size: 1.8rem; +} + +h2 { + font-size: 1.4rem; +} + +h4 { + font-size: 0.9rem; + color: #3C3C3C; +} + +p { + margin: 0.6rem 0 0.6rem; +} + .container { - display: -ms-flexbox; - display: -webkit-flex; + margin: 0.5rem 0 0; display: flex; + flex-direction: row; +} + +.col-container { + display: flex; + flex-direction: column; +} - -ms-flex-pack: justify; - justify-content: space-around; +hr { + content: 0; + height: 0; + border: 0; + border-bottom: 3px solid #121212; + margin: 0.9rem 0 0.8rem; +} + +.headline-note { + color: #D0021B; + font-size: 1.0rem; + font-weight: normal; + margin: 0 0.4rem 0; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +.subheadline { + color: #565656; } .pane { - max-width: 20%; - padding: 0 1em; + flex: 1; +} + +.row { + flex: 1; } .pane p.desc { @@ -75,13 +126,34 @@ body { right: 0; width: 50%; } +#editor { + font-size: 0.9rem; +} #serialized-mobiledoc, #mobiledoc-to-load { overflow: hidden; padding: 0.25em; } #serialized-mobiledoc { white-space: pre; - background-color: #080808; + font-size: 0.7rem; +} +.output { + margin: 0.3rem; + background: #ffffff; + border: 2px solid #8A888A; + padding: 0.3rem 0 0.3rem 0.5rem; +} +.output.full-left { + margin-left: 0; +} +.output.full-right { + margin-right: 0; +} +.serialized-mobiledoc-wrapper { + line-height: 1.1; +} +#rendered-mobiledoc { + font-size: 0.9rem; } #mobiledoc-to-load { } @@ -101,7 +173,6 @@ body { background-color: transparent; color: #c0c5ce; padding: 5em 1em 1em; - overflow: auto; -webkit-overflow-scrolling: touch; position: absolute; top: 0; diff --git a/demo/demo.js b/demo/demo.js index 83cc2f66f..55e536039 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -60,7 +60,7 @@ var selfieCard = { alert('error getting video feed'); }; if (!navigator.webkitGetUserMedia) { - alert('Cannot get your video because no navigator.webkitGetUserMedia'); + alert('This only works in Chrome (no navigator.webkitGetUserMedia)'); } navigator.webkitGetUserMedia(videoObj, function(stream) { video.src = window.webkitURL.createObjectURL(stream); @@ -99,7 +99,9 @@ var cardWithEditMode = { button.innerText = 'Change to edit'; button.onclick = env.edit; - card.appendChild(button); + if (env.edit) { + card.appendChild(button); + } element.appendChild(card); } }, @@ -136,7 +138,9 @@ var cardWithInput = { button.innerText = 'Edit'; button.onclick = env.edit; - card.appendChild(button); + if (env.edit) { + card.appendChild(button); + } element.appendChild(card); } }, @@ -172,7 +176,7 @@ var ContentKitDemo = exports.ContentKitDemo = { syncCodePane: function(editor) { var codePaneJSON = document.getElementById('serialized-mobiledoc'); var mobiledoc = editor.serialize(); - codePaneJSON.innerHTML = this.syntaxHighlight(mobiledoc); + codePaneJSON.innerText = JSON.stringify(mobiledoc, null, ' '); var cards = { 'simple-card': simpleCard, @@ -282,8 +286,7 @@ function isValidJSON(string) { } } -function attemptEditorReboot(editor, textarea) { - var textPayload = $(textarea).val(); +function attemptEditorReboot(editor, textPayload) { if (isValidJSON(textPayload)) { var mobiledoc = readMobiledoc(textPayload); if (editor) { @@ -422,14 +425,13 @@ $(function() { textarea.val(window.JSON.stringify(mobiledoc, false, 2)); textarea.on('input', function() { - attemptEditorReboot(editor, textarea); }); $('#select-mobiledoc').on('change', function() { var mobiledocName = $(this).val(); var mobiledoc = sampleMobiledocs[mobiledocName]; - textarea.val(window.JSON.stringify(mobiledoc, false, 2)); - textarea.trigger('input'); + var text = window.JSON.stringify(mobiledoc, false, 2); + attemptEditorReboot(editor, text); }); bootEditor(editorEl, mobiledoc); diff --git a/demo/index.html b/demo/index.html index 6f993a2a4..71b29d319 100644 --- a/demo/index.html +++ b/demo/index.html @@ -10,86 +10,62 @@ - + +
+

Content-Kitalpha!

+

A WYSIWYG editor for rich content

+
+
+
+

+ Content-Kit is a publishing solution designed for both text and + dynamically rendered cards. Posts are serialized into Mobiledoc, and + rendered to DOM in a reader's browser. +

+

+ Read more on the content-kit-editor + GitHub repo, or on the announcement blog post. +

+
+
+
+

Try a Demo

+
-
-

mobiledoc to load

-

- 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: - -

-
- +
+ +
+
-

editor

-

- The live-editing surface. Changes here are serialized to mobiledoc - format and displayed to the right. -

-
+
+

Mobiledoc Output

+

+        
-
-

serialized mobiledoc

-

- When the editor updates, it prints its serialized mobiledoc here. -

-
+
+

Rendered with DOM-Renderer

+
+
-
-

rendered mobiledoc (dom)

-

- This is the output of using the runtime (client-side) - mobiledoc-dom-renderer - on the serialized mobiledoc. -

-
- -
- -

innerHTML of editor surface

-

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

-
-
- -
-

rendered mobiledoc (html)

-

- This is the output of using the server-side - mobiledoc-html-renderer - on the serialized mobiledoc. -

-
-
-