Created
August 12, 2014 23:15
-
-
Save yjkogan/e03fcefe367271a639a1 to your computer and use it in GitHub Desktop.
Vue.js "EditableText" directive.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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/ | |
* | |
*/ | |
define(function () { | |
/** | |
* Given an html element (with contenteditable="true"), returns the current cursor selection. | |
*/ | |
function saveSelection(containerEl) { | |
// If a jQuery object got passed in, get the raw HTML element | |
if (containerEl instanceof jQuery) { | |
containerEl = containerEl.get(0); | |
} | |
if (window.getSelection && document.createRange) { | |
var range = window.getSelection().getRangeAt(0); | |
var preSelectionRange = range.cloneRange(); | |
preSelectionRange.selectNodeContents(containerEl); | |
preSelectionRange.setEnd(range.startContainer, range.startOffset); | |
var 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); | |
var 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. | |
*/ | |
function restoreSelection(containerEl, savedSel) { | |
// If a jQuery object got passed in, get the raw HTML element | |
if (containerEl instanceof jQuery) { | |
containerEl = containerEl.get(0); | |
} | |
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(); | |
} | |
} | |
return { | |
restoreSelection: restoreSelection, | |
saveSelection: saveSelection | |
} | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Tests for the contenteditable_selection utility | |
* Uses Mocha and expect.js | |
*/ | |
define(function(require) { | |
var selectionHelper = require('utils/contenteditable_selection'); | |
var el = $('<div id="selection-test-div" contenteditable="true">This text is at least 15 characters long.</div>'); | |
describe('utils/contenteditable-selection', function() { | |
before(function() { | |
$('body').append(el); | |
}); | |
after(function() { | |
el.remove(); | |
}); | |
describe('selection tests with basic jQuery object', function() { | |
afterEach(function() { | |
el.blur(); | |
}); | |
it('Expect saving a restored cursor to match', function() { | |
var expectedSelection = {start: 7, end: 7}; // start == end means it's just the cursor | |
selectionHelper.restoreSelection(el, expectedSelection); | |
var selection = selectionHelper.saveSelection(el); | |
expect(selection).to.eql(expectedSelection); | |
}); | |
it('Expect saving a restored selection to match', function() { | |
var expectedSelection = {start: 3, end: 15}; | |
selectionHelper.restoreSelection(el, expectedSelection); | |
var selection = selectionHelper.saveSelection(el); | |
expect(selection).to.eql(expectedSelection); | |
}) | |
}); | |
describe('selection tests with basic DOM element', function() { | |
var rawDOMel; | |
afterEach(function() { | |
el.blur(); | |
}); | |
before(function() { | |
rawDOMel = el.get(0); | |
}); | |
it('Expect saving a restored cursor to match', function() { | |
var expectedSelection = {start: 7, end: 7}; // start == end means it's just the cursor | |
selectionHelper.restoreSelection(rawDOMel, expectedSelection); | |
var selection = selectionHelper.saveSelection(rawDOMel); | |
expect(selection).to.eql(expectedSelection); | |
}); | |
it('Expect saving a restored selection to match', function() { | |
var expectedSelection = {start: 3, end: 15}; | |
selectionHelper.restoreSelection(rawDOMel, expectedSelection); | |
var selection = selectionHelper.saveSelection(rawDOMel); | |
expect(selection).to.eql(expectedSelection); | |
}) | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Turns any non-form element into a contenteditable | |
* HTML element with a two-way data binding to the key passed in | |
* as an argument. Takes exactly one argument. | |
* | |
* Roughly equivalent to (and modeled after) the v-model directive | |
* when applied to form elements. | |
* IMPORTANT: This will not work on form elements! Use v-model instead. | |
*/ | |
define(function() { | |
var selectionHelper = require('utils/contenteditable_selection'); | |
var ESCAPE_KEY = 27; | |
var attrToChange = 'innerHTML'; | |
var savedSelection; | |
return { | |
bind: function () { | |
var self = this, | |
el = self.el; | |
// Make the content editable | |
$(el).attr('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); | |
// 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 | |
try { | |
savedSelection = selectionHelper.saveSelection(el); | |
} catch (e) {} | |
self._set(); | |
}; | |
el.addEventListener('input', self.onInput) | |
}, | |
/** | |
* Resets the cursor/selection to the start/end specified in selection | |
* | |
* @param selection Object of type {start: number, end: number} | |
* @private | |
*/ | |
_resetCursor: function(selection) { | |
if (selection !== undefined) { | |
selectionHelper.restoreSelection(this.el, selection); | |
} | |
}, | |
_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] = value == null ? '' : 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 | |
this._resetCursor(savedSelection); | |
}, | |
unbind: function () { | |
var el = this.el; | |
el.removeEventListener('input', this.onInput); | |
el.removeEventListener('keyup', this.onEsc); | |
el.removeEventListener('focus', this.onFocus); | |
} | |
} | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment