diff --git a/component.json b/component.json index 863670201a8..f26089bfef3 100644 --- a/component.json +++ b/component.json @@ -16,6 +16,7 @@ "src/viewmodel.js", "src/binding.js", "src/observer.js", + "src/contenteditable-selection.js", "src/directive.js", "src/exp-parser.js", "src/template-parser.js", diff --git a/src/contenteditable-selection.js b/src/contenteditable-selection.js new file mode 100644 index 00000000000..2b21767be17 --- /dev/null +++ b/src/contenteditable-selection.js @@ -0,0 +1,98 @@ +/** + * In order to get the cursor position / selection for a contenteditable HTML element, we need to do some + * fancy stuff. This is necessary for the editable-text directive (and possibly others in the future). This code comes + * from the following stack overflow post: + * + * http://stackoverflow.com/questions/13949059/persisting-the-changes-of-range-objects-after-selection-in-html/13950376#13950376 + * Example here: + * http://jsfiddle.net/WeWy7/3/ + * + */ +module.exports = { + /** + * Given an html element (with contenteditable="true"), returns the current cursor selection. + * @param containerEl + * @returns {{start: Number, end: number}} + */ + saveSelection: function saveSelection(containerEl) { + var start; + if (window.getSelection && document.createRange) { + var range = window.getSelection().getRangeAt(0); + var preSelectionRange = range.cloneRange(); + preSelectionRange.selectNodeContents(containerEl); + preSelectionRange.setEnd(range.startContainer, range.startOffset); + start = preSelectionRange.toString().length; + + return { + start: start, + end: start + range.toString().length + } + } else if (document.selection && document.body.createTextRange) { + // This is for IE... + var selectedTextRange = document.selection.createRange(); + var preSelectionTextRange = document.body.createTextRange(); + preSelectionTextRange.moveToElementText(containerEl); + preSelectionTextRange.setEndPoint("EndToStart", selectedTextRange); + start = preSelectionTextRange.text.length; + + return { + start: start, + end: start + selectedTextRange.text.length + } + } + }, + + /** + * Given an html element, resets the selection to the start/end specified in savedSel. Expectation + * is that savedSel was generated by the saveSelection function. + * + * @param containerEl + * @param savedSel {{start: Number, end: number}} + */ + restoreSelection: function restoreSelection(containerEl, savedSel) { + if (window.getSelection && document.createRange) { + var charIndex = 0, range = document.createRange(); + range.setStart(containerEl, 0); + range.collapse(true); + var nodeStack = [containerEl], node, foundStart = false, stop = false; + + // This while loop is super confusing. This part of DOM exploration is greek to me though and + // I trust stack overflow more than trying to figure this out from first principles. + // Here's the w3 article on nodeType http://www.w3schools.com/jsref/prop_node_nodetype.asp + // nodeType == 3 is text. Basically it's taking the element and trying to find the text part of the element + // Once it has that, it moves one chunk of text at a time until it finds the beginning / end + // of the desired selection, and then creates that range. + while (!stop && (node = nodeStack.pop())) { + if (node.nodeType === 3) { + var nextCharIndex = charIndex + node.length; + if (!foundStart && savedSel.start >= charIndex && savedSel.start <= nextCharIndex) { + range.setStart(node, savedSel.start - charIndex); + foundStart = true; + } + if (foundStart && savedSel.end >= charIndex && savedSel.end <= nextCharIndex) { + range.setEnd(node, savedSel.end - charIndex); + stop = true; + } + charIndex = nextCharIndex; + } else { + var i = node.childNodes.length; + while (i--) { + nodeStack.push(node.childNodes[i]); + } + } + } + + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + } else if (document.selection && document.body.createTextRange) { + // This is for IE... + var textRange = document.body.createTextRange(); + textRange.moveToElementText(containerEl); + textRange.collapse(true); + textRange.moveEnd("character", savedSel.end); + textRange.moveStart("character", savedSel.start); + textRange.select(); + } + } +} diff --git a/src/directives/editable_text.js b/src/directives/editable_text.js new file mode 100644 index 00000000000..6ea31648e9c --- /dev/null +++ b/src/directives/editable_text.js @@ -0,0 +1,92 @@ +var utils = require('../utils'), + ESCAPE_KEY = 27, + ENTER_KEY = 13, + attrToChange = 'textContent' + +/** + * Two-way binding for form input elements + */ +module.exports = { + + _savedSelection: null, // Default Value + + bind: function () { + + var self = this, + el = self.el + + // Make the content editable + el.setAttribute('contenteditable', true) + + // On escape, reset to the initial value and deselect (blur) + self.onEsc = function(e) { + if (e.keyCode === ESCAPE_KEY) { + el[attrToChange] = self.initialValue || '' + self._set() + el.blur() + } + } + el.addEventListener('keyup', this.onEsc) + + self.onEnter = function(e) { + if (e.keyCode === ENTER_KEY) { + e.preventDefault() + el.blur() + } + } + el.addEventListener('keydown', this.onEnter) + + // On focus, store the initial value so it can be reset on escape + self.onFocus = function() { + self.initialValue = el[attrToChange] + } + el.addEventListener('focus', this.onFocus) + + self.onInput = function () { + // if this directive has filters + // we need to let the vm.$set trigger + // update() so filters are applied. + // therefore we have to record cursor position (selection) + // so that after vm.$set changes the input + // value we can put the cursor back at where it is + this._savedSelection = utils.selectionHelper.saveSelection(el) + + self._set() + } + + el.addEventListener('input', self.onInput) + + // FIXME: We don't support IE 9 so I never solved whatever issues exist with backspace / del / cut + }, + + _set: function () { + this.vm.$set(this.key, this.el[attrToChange]) + }, + + update: function (value, init) { + // sync back inline value if initial data is undefined + if (init && value === undefined) { + return this._set() + } + + this.el[attrToChange] = typeof value !== 'string' ? '' : value + + // Since updates are async, we need to reset the position of the cursor after it fires + // v-model tries to do this with setTimeout(cb, 0) but if there's a filter and you type + // too fast, there's a race condition where the timeout can fire before + // update, moving the cursor back to the front. Having this here guarantees the cursor + // is reset after update. + // See the comment in self.set for additional context + if (this._savedSelection) { + utils.selectionHelper.restoreSelection(this.el, this._savedSelection) + } + }, + + unbind: function () { + var el = this.el + el.removeEventListener('input', this.onInput) + el.removeEventListener('keyup', this.onEsc) + el.removeEventListener('keydown', this.onEnter) + el.removeEventListener('focus', this.onFocus) + } +} diff --git a/src/utils.js b/src/utils.js index 4e72973ce97..c67807236b6 100644 --- a/src/utils.js +++ b/src/utils.js @@ -37,6 +37,11 @@ var utils = module.exports = { */ parseTemplateOption: require('./template-parser.js'), + /** + * Helper function for getting/restoring the cursor in contenteditable elements + */ + selectionHelper: require('./contenteditable-selection.js'), + /** * get a value from an object keypath */ diff --git a/test/unit/specs/utils.js b/test/unit/specs/utils.js index 41717177889..641fcadfe54 100644 --- a/test/unit/specs/utils.js +++ b/test/unit/specs/utils.js @@ -488,6 +488,29 @@ describe('Utils', function () { describe('objectToArray', function () { // TODO + }), + describe('contenteditable-selection', function() { + var selectionHelper = utils.selectionHelper + it('Expect saving a restored cursor to match', function() { + var template = '