Skip to content

Instantly share code, notes, and snippets.

@slorber
Created June 29, 2016 15:09
Show Gist options
  • Save slorber/b84c6c7c2d2c3c6250c53a001b674688 to your computer and use it in GitHub Desktop.
Save slorber/b84c6c7c2d2c3c6250c53a001b674688 to your computer and use it in GitHub Desktop.
"use strict";
var _ = require('lodash');
var $ = require("jquery");
var React = require("react");
var ReactDOM = require("react-dom");
/*
This component is technical only.
It should have no dependency on stample code!
*/
var UncontrolledContentEditable = React.createClass({
propTypes: {
// The HTML to set in this content editable
html: React.PropTypes.string.isRequired,
// What to do on changes (key input,paste...)
onChange: React.PropTypes.func.isRequired,
// Gives the possibility to "reformat" the dom tree before emitting the change
parser: React.PropTypes.func,
// Adds a placeholder attribute to the div.
// Notice that it requires some CSS to work:
// [contenteditable][placeholder]:empty:not(:focus):before { content: attr(placeholder); }
placeholder: React.PropTypes.string,
// Permits to eventually "lock" the contenteditable. It means that the contenteditable will not be editable anymore
// This is useful to change from edition to read-only mode
locked: React.PropTypes.bool,
// This can be useful to be able to disable the listening of changes
// This allow external libraries to be able to manipulate the content without triggering parsing/change callback
// This was primarily done to be able to display annotations on text, using AnnotatorJS
disableChangeListener: React.PropTypes.bool
},
api() {
const contentEditable = ReactDOM.findDOMNode(this);
const self = this;
return {
// Insert HTML/node to the last place where the cursor was at when the selection was saved
// If no cursor selection was saved, then the html node will be added at the end
insertHtml(htmlOrNode) {
const node = $(htmlOrNode)[0];
console.debug("insertHtml at range=",self._savedSelectionRange || "END");
restoreSelectionRange(self._savedSelectionRange);
self._savedSelectionRange = undefined;
insertNodeOverSelection(node,contentEditable);
self.emitChange();
self.clearSelectionRanges();
},
// Before displaying an insertHtml/embed element popup, you can call this, so that when you decide
// to insertHtml, it will insert the html at the correct place
saveSelectionRangeForNextInsertHtml() {
if ( self._temporaryLastSelectionRange ) {
self._savedSelectionRange = self._temporaryLastSelectionRange;
console.debug("Selection range saved: ",self._savedSelectionRange);
}
}
}
},
clearSelectionRanges() {
this._temporaryLastSelectionRange = undefined;
this._savedSelectionRange = undefined;
},
shouldComponentUpdate: function(nextProps) {
// Special case to avoid cursor jumps
// See http://stackoverflow.com/a/27255103/82609
var htmlChanged = nextProps.html !== ReactDOM.findDOMNode(this).innerHTML;
//
var lockedChanged = this.props.locked !== nextProps.locked;
var onChangeChanged = this.props.onChange !== nextProps.onChange;
var parserChanged = this.props.parser !== nextProps.parser;
var placeholderChanged = this.props.placeholder !== nextProps.placeholder;
var shouldUpdate = htmlChanged || lockedChanged || onChangeChanged || parserChanged || placeholderChanged;
if ( shouldUpdate ) {
// console.debug("UncontrolledContentEditable -> should update ",htmlChanged,lockedChanged,onChangeChanged,parserChanged,placeholderChanged);
}
return shouldUpdate;
},
componentDidUpdate: function() {
const node = ReactDOM.findDOMNode(this);
const html = this.props.html;
// Bypass VDOM diff by React and updates the dom if it is not correct
// See http://stackoverflow.com/a/27255103/82609
if ( node.innerHTML !== html ) {
node.innerHTML = this.props.html;
// If DOM node is still different after that, it's because the browser decided to reformat it!
// we emit the reformatting as a change so that we are sync with the store!
// (otherwise shouldComponentUpdate may not kick in next time, leading to re-render!)
// See https://github.com/lovasoa/react-contenteditable/issues/18
const finalInnerHtml = node.innerHTML;
if ( finalInnerHtml !== html ) {
this.props.onChange(finalInnerHtml);
}
}
this.clearSelectionRanges();
},
emitChange: function() {
if ( this.props.disableChangeListener ) {
return;
}
if ( this.props.parser ) {
this.props.parser(ReactDOM.findDOMNode(this));
}
var html = ReactDOM.findDOMNode(this).innerHTML;
if ( html !== this.lastHtml) {
this.props.onChange(html);
}
this.lastHtml = html;
this.clearSelectionRanges();
},
handleBlur(e) {
if ( this.props.onBlur ) {
this.props.onBlur(e)
}
// We have to store the selectionRange before the blur event default behavior because otherwise the selection become unavailable
// But we store it for a very limited amount of time under which it is allowed to save that range through the API
// Used to embed html (img/video) in correct place of the doc
this._temporaryLastSelectionRange = getSelectionRange(ReactDOM.findDOMNode(this));
console.debug("OnBlur, selectionRange staged:",this._temporaryLastSelectionRange);
if ( this._temporaryLastSelectionRangeTimeout ) clearTimeout(this._temporaryLastSelectionRangeTimeout);
this._temporaryLastSelectionRangeTimeout = setTimeout(() => this._temporaryLastSelectionRange = undefined,100);
},
render: function() {
return <div {...this.props}
onBlur={this.handleBlur}
onInput={this.emitChange}
data-placeholder={this.props.placeholder}
contentEditable={this.props.locked ? "false" : "true"}
dangerouslySetInnerHTML={{__html: this.props.html}}></div>;
}
});
module.exports = UncontrolledContentEditable;
// See http://stackoverflow.com/a/4826688/82609
function isOrContainsNode(ancestor, descendant) {
var node = descendant;
while (node) {
if (node === ancestor) return true;
node = node.parentNode;
}
return false;
}
function insertNodeOverSelection(node, containerNode) {
var sel, range, html;
if (window.getSelection) {
sel = window.getSelection();
if (sel.getRangeAt && sel.rangeCount) {
range = sel.getRangeAt(0);
if (isOrContainsNode(containerNode, range.commonAncestorContainer)) {
range.deleteContents();
range.insertNode(node);
console.debug("1");
// Move the caret immediately after the inserted node
range.setStartAfter(node);
range.collapse(true);
} else {
console.debug("2",containerNode,node);
console.debug("2",containerNode.innerHTML);
containerNode.appendChild(node);
console.debug("2",containerNode.innerHTML);
}
} else {
console.debug("3");
containerNode.appendChild(node);
}
}
// for IE I think
else if (document.selection && document.selection.createRange) {
range = document.selection.createRange();
if (isOrContainsNode(containerNode, range.parentElement())) {
html = (node.nodeType == 3) ? node.data : node.outerHTML;
range.pasteHTML(html);
} else {
containerNode.appendChild(node);
}
}
else {
containerNode.appendChild(node);
}
}
function getSelectionRange(containerNode) {
if (window.getSelection) {
var sel = window.getSelection();
if (sel.getRangeAt && sel.rangeCount) {
var range = sel.getRangeAt(0);
if ( isOrContainsNode(containerNode, range.commonAncestorContainer) ) {
return range.cloneRange();
}
} else if (document.selection && document.selection.createRange) {
return document.selection.createRange();
}
}
}
function restoreSelectionRange(range) {
if ( range ) {
if (window.getSelection) {
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} else if (document.selection && range.select) {
range.select();
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment