Skip to content

Instantly share code, notes, and snippets.

@yjkogan
Created August 12, 2014 23:15
Show Gist options
  • Save yjkogan/e03fcefe367271a639a1 to your computer and use it in GitHub Desktop.
Save yjkogan/e03fcefe367271a639a1 to your computer and use it in GitHub Desktop.
Vue.js "EditableText" directive.
/**
* 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
}
});
/**
* 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);
})
});
});
});
/**
* 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