From 674d399919d27a4a008a75f8357c8c185eeb4007 Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Fri, 31 Jul 2015 16:17:47 -0400 Subject: [PATCH] Refactor editor to delegate selection methods to `Cursor` * `Post.insertSectionAfter` can take a null reference section, like the DOM method `insertAfter` * remove unused methods from cursor --- src/js/editor/editor.js | 135 +++++++++---------------------- src/js/models/cursor.js | 74 +---------------- src/js/models/post.js | 12 +-- src/js/utils/dom-utils.js | 9 ++- src/js/utils/selection-utils.js | 15 ---- tests/unit/editor/editor-test.js | 13 ++- 6 files changed, 65 insertions(+), 193 deletions(-) diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index 8647005d1..a9bd63bff 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -16,8 +16,7 @@ import CardCommand from '../commands/card'; import Keycodes from '../utils/keycodes'; import { - getSelectionBlockElement, - getCursorOffsetInElement + getSelectionBlockElement } from '../utils/selection-utils'; import EventEmitter from '../utils/event-emitter'; @@ -29,8 +28,8 @@ import MobiledocRenderer from '../renderers/mobiledoc'; import { toArray, mergeWithOptions } from 'content-kit-utils'; import { - detectParentNode, clearChildNodes, + addClassName } from '../utils/dom-utils'; import { forEach @@ -42,6 +41,8 @@ import Cursor from '../models/cursor'; import { MARKUP_SECTION_TYPE } from '../models/markup-section'; import { generateBuilder } from '../utils/post-builder'; +export const EDITOR_ELEMENT_CLASS_NAME = 'ck-editor'; + const defaults = { placeholder: 'Write here...', spellcheck: true, @@ -109,16 +110,6 @@ function bindAutoTypingListeners(editor) { }); } -function handleSelection(editor) { - return () => { - if (editor.cursor.hasSelection()) { - editor.hasSelection(); - } else { - editor.hasNoSelection(); - } - }; -} - function bindSelectionEvent(editor) { /** * The following events/sequences can create a selection and are handled: @@ -131,11 +122,16 @@ function bindSelectionEvent(editor) { * * ctrl-click -> context menu -> click "select all" */ + const toggleSelection = () => { + return editor.cursor.hasSelection() ? editor.hasSelection() : + editor.hasNoSelection(); + }; + // mouseup will not properly report a selection until the next tick, so add a timeout: - const mouseupHandler = () => setTimeout(handleSelection(editor)); + const mouseupHandler = () => setTimeout(toggleSelection); editor.addEventListener(document, 'mouseup', mouseupHandler); - const keyupHandler = handleSelection(editor); + const keyupHandler = toggleSelection; editor.addEventListener(editor.element, 'keyup', keyupHandler); } @@ -203,7 +199,7 @@ class Editor { this._parser = PostParser; this._renderer = new Renderer(this, this.cards, this.unknownCardHandler, this.cardOptions); - this.applyClassName(); + this.applyClassName(EDITOR_ELEMENT_CLASS_NAME); this.applyPlaceholder(); element.spellcheck = this.spellcheck; @@ -238,9 +234,7 @@ class Editor { showForTag: 'a' })); - if (this.autofocus) { - element.focus(); - } + if (this.autofocus) { element.focus(); } } addView(view) { @@ -271,6 +265,8 @@ class Editor { rerender() { let postRenderNode = this.post.renderNode; + + // if we haven't rendered this renderNode before, mark it dirty if (!postRenderNode.element) { postRenderNode.element = this.element; postRenderNode.markDirty(); @@ -432,11 +428,6 @@ class Editor { this.trigger('update'); } - getActiveMarkers() { - const cursor = this.cursor; - return cursor.activeMarkers; - } - getActiveSections() { const cursor = this.cursor; return cursor.activeSections; @@ -452,23 +443,8 @@ class Editor { return blockElements.indexOf(selectionEl); } - getCursorIndexInCurrentBlock() { - var currentBlock = getSelectionBlockElement(); - if (currentBlock) { - return getCursorOffsetInElement(currentBlock); - } - return -1; - } - - applyClassName() { - var editorClassName = 'ck-editor'; - var editorClassNameRegExp = new RegExp(editorClassName); - var existingClassName = this.element.className; - - if (!editorClassNameRegExp.test(existingClassName)) { - existingClassName += (existingClassName ? ' ' : '') + editorClassName; - } - this.element.className = existingClassName; + applyClassName(className) { + addClassName(this.element, className); } applyPlaceholder() { @@ -518,16 +494,11 @@ class Editor { 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); - } + let previousSectionRenderNode = previousSection && previousSection.renderNode; + this.post.insertSectionAfter(section, previousSection); + this._renderTree.node.insertAfter(sectionRenderNode, previousSectionRenderNode); } + // may cause duplicates to be included let section = sectionRenderNode.postNode; sectionsInDOM.push(section); @@ -535,21 +506,23 @@ class Editor { }); // remove deleted nodes - let i; - for (i=this.post.sections.length-1;i>=0;i--) { - let section = this.post.sections[i]; + const deletedSections = []; + forEach(this.post.sections, (section) => { + if (!section.renderNode) { + throw new Error('All sections are expected to have a renderNode'); + } + if (sectionsInDOM.indexOf(section) === -1) { - if (section.renderNode) { - section.renderNode.scheduleForRemoval(); - } else { - throw new Error('All sections are expected to have a renderNode'); - } + deletedSections.push(section); } - } + }); + forEach(deletedSections, (s) => s.renderNode.scheduleForRemoval()); - // reparse the section(s) with the cursor - const sectionsWithCursor = this.getSectionsWithCursor(); - sectionsWithCursor.forEach((section) => { + // reparse the new section(s) with the cursor + // to ensure that we catch any changed html that the browser might have + // added + const sectionsWithCursor = this.cursor.activeSections; + forEach(sectionsWithCursor, (section) => { if (newSections.indexOf(section) === -1) { this.reparseSection(section); } @@ -567,7 +540,6 @@ class Editor { // // 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); @@ -598,41 +570,6 @@ class Editor { } } - getSectionsWithCursor() { - return this.getRenderNodesWithCursor().map( renderNode => { - return renderNode.postNode; - }); - } - - getRenderNodesWithCursor() { - const selection = document.getSelection(); - if (selection.rangeCount === 0) { - return null; - } - - const range = selection.getRangeAt(0); - - let { startContainer:startElement, endContainer:endElement } = range; - - let 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); - - let nodes = []; - let node = startRenderNode; - while (node && (!endRenderNode.nextSibling || endRenderNode.nextSibling !== node)) { - nodes.push(node); - node = node.nextSibling; - } - - return nodes; - } - reparseSection(section) { this._parser.reparseSection(section, this._renderTree); } @@ -648,7 +585,7 @@ class Editor { insertSectionAtCursor(newSection) { let newRenderNode = this._renderTree.buildRenderNode(newSection); - let renderNodes = this.getRenderNodesWithCursor(); + let renderNodes = this.cursor.activeSections.map(s => s.renderNode); let lastRenderNode = renderNodes[renderNodes.length-1]; lastRenderNode.parentNode.insertAfter(newRenderNode, lastRenderNode); this.post.insertSectionAfter(newSection, lastRenderNode.postNode); diff --git a/src/js/models/cursor.js b/src/js/models/cursor.js index 870ba68cc..8512a6fba 100644 --- a/src/js/models/cursor.js +++ b/src/js/models/cursor.js @@ -8,12 +8,10 @@ import { } from '../utils/selection-utils'; import { - detectParentNode, - containsNode, - walkTextNodes + detectParentNode } from '../utils/dom-utils'; -const Cursor = class Cursor { +export default class Cursor { constructor(editor) { this.editor = editor; this.renderTree = editor._renderTree; @@ -33,13 +31,6 @@ const Cursor = class Cursor { 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; @@ -73,58 +64,6 @@ const Cursor = class Cursor { }; } - 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; @@ -135,9 +74,7 @@ const Cursor = class Cursor { const { startContainer, endContainer } = range; const isSectionElement = (element) => { - return detect(sections, (section) => { - return section.renderNode.element === element; - }); + return detect(sections, (s) => s.renderNode.element === element); }; const {result:startSection} = detectParentNode(startContainer, isSectionElement); const {result:endSection} = detectParentNode(endContainer, isSectionElement); @@ -174,7 +111,4 @@ const Cursor = class Cursor { } selection.addRange(r); } -}; - -export default Cursor; - +} diff --git a/src/js/models/post.js b/src/js/models/post.js index 09b79d75c..d4c21a5c7 100644 --- a/src/js/models/post.js +++ b/src/js/models/post.js @@ -17,14 +17,16 @@ export default class Post { this.removeSection(section); } insertSectionAfter(section, previousSection) { - var i, l; - for (i=0,l=this.sections.length;i {}) { } } - // see https://github.com/webmodules/node-contains/blob/master/index.js function containsNode(parentNode, childNode) { const isSame = () => parentNode === childNode; @@ -112,6 +111,11 @@ function getAttributesArray(element) { return result; } +function addClassName(element, className) { + // FIXME-IE IE10+ + element.classList.add(className); +} + export { detectParentNode, containsNode, @@ -119,5 +123,6 @@ export { getAttributes, getAttributesArray, walkDOMUntil, - walkTextNodes + walkTextNodes, + addClassName }; diff --git a/src/js/utils/selection-utils.js b/src/js/utils/selection-utils.js index a0b7f15f2..b549df211 100644 --- a/src/js/utils/selection-utils.js +++ b/src/js/utils/selection-utils.js @@ -105,20 +105,6 @@ function selectNode(node) { selection.addRange(range); } -function getCursorOffsetInElement(element) { - // http://stackoverflow.com/questions/4811822/get-a-ranges-start-and-end-offsets-relative-to-its-parent-container/4812022#4812022 - var caretOffset = 0; - var selection = window.getSelection(); - if (selection.rangeCount > 0) { - var range = selection.getRangeAt(0); - var preCaretRange = range.cloneRange(); - preCaretRange.selectNodeContents(element); - preCaretRange.setEnd(range.endContainer, range.endOffset); - caretOffset = preCaretRange.toString().length; - } - return caretOffset; -} - export { getDirectionOfSelection, getSelectionElement, @@ -128,7 +114,6 @@ export { tagsInSelection, restoreRange, selectNode, - getCursorOffsetInElement, clearSelection, isSelectionInElement }; diff --git a/tests/unit/editor/editor-test.js b/tests/unit/editor/editor-test.js index d6c6466cd..c7aec8eed 100644 --- a/tests/unit/editor/editor-test.js +++ b/tests/unit/editor/editor-test.js @@ -1,5 +1,5 @@ import { MOBILEDOC_VERSION } from 'content-kit-editor/renderers/mobiledoc'; -import Editor from 'content-kit-editor/editor/editor'; +import Editor, { EDITOR_ELEMENT_CLASS_NAME } from 'content-kit-editor/editor/editor'; const { module, test } = window.QUnit; @@ -35,7 +35,16 @@ test('creating an editor without a class name adds appropriate class', (assert) editorElement.className = ''; var editor = new Editor(document.getElementById('editor1')); - assert.equal(editor.element.className, 'ck-editor'); + assert.equal(editor.element.className, EDITOR_ELEMENT_CLASS_NAME); +}); + +test('creating an editor adds EDITOR_ELEMENT_CLASS_NAME if not there', (assert) => { + editorElement.className = 'abc def'; + + var editor = new Editor(document.getElementById('editor1')); + const hasClass = (className) => editor.element.className.indexOf(className) !== -1; + assert.ok(hasClass(EDITOR_ELEMENT_CLASS_NAME), 'has editor el class name'); + assert.ok(hasClass('abc') && hasClass('def'), 'preserves existing class names'); }); test('editor fires update event', (assert) => {