Skip to content

Instantly share code, notes, and snippets.

@hongymagic
Created August 2, 2012 05:11
Show Gist options
  • Save hongymagic/3233868 to your computer and use it in GitHub Desktop.
Save hongymagic/3233868 to your computer and use it in GitHub Desktop.
HTMLPreElement extensions
# Other VCS
.svn/
.CVS/
.hg/
# Ignore these asset directories
psd/
# Node.js modules, use `npm install`
node_modules/
# Mac OS X specific files
.DS_Store
# Windows specific files
thumbs.db

ContentEditable + DOM Manipulation + Undo = FUCKING NIGHTMARE

A quick mock up to test which browsers support DOM transaction history. Mainly to do with native undo command.

TEST IT YOURSELF

http://dom-transaction-undo-test.herokuapp.com

CAUTION

On some browsers, the caret may indicate in wrong position when you hit enter. Don't worry about it and continue.

innerText

  • Chrome: works correctly
  • Firefox: works for last word
  • Opera: works for last word

textContent

  • Chrome: works up until last DOM manipulation
  • Firefox: no good
  • Opera: works up until last DOM manipulation

editor.empty().appendChild(document.createTextNode("text"))

  • Chrome: works up until last DOM manipulation
  • Firefox: works up until last DOM manipulation
  • Opera: works up until last DOM manipulation

editor.firstChild.nodeValue = "text"

  • Chrome: works correctly
  • Firefox: works okay 99% of the time
  • Opera: works okay 99% of the time

Summary

  • Chrome: innerText or nodeValue
  • Safari: innerText or nodeValue
  • Firefox: nodeValue
  • Opera: nodeValue
(function() {
"use strict";
var editorIdPrefix = 'editor-';
var methods;
// Each editor has its own replacement methods, this is similar to _setText in EE
methods = ['setInnerText', 'setTextContent', 'setTextNodeByReplacement', 'setTextNode'];
methods.forEach(function (method) {
var editor = document.getElementById(editorIdPrefix + method);
editor.addEventListener('keydown', keydownHandler.call(editor, method), false);
editor.addEventListener('keyup', keyupHandler.call(editor, method), false);
});
function insertNewline (pre, method) {
insertText(pre, method, '\n');
}
function insertText (pre, method, value) {
if (!value) {
return false;
}
var text = pre.text;
var ss = pre.selectionStart;
var se = pre.selectionEnd;
var before = text.slice(0, ss);
var selection = text.slice(ss, se);
var after = text.slice(se);
before += value;
selection = '';
// Set text
HTMLPreElement.prototype[method].call(pre, before + selection + after);
// Restore cursor position
ss += value.length;
se = ss;
pre.setSelectionRange(ss, se);
}
function keydownHandler (method) {
var editor = this;
return function (event) {
var command = isCommand(event);
var key = event.keyCode;
if (key == 13) {
event.preventDefault();
event.stopPropagation();
insertNewline(this, method);
return false;
}
}.bind(editor);
}
function keyupHandler (method) {
var editor = this;
return function (event) {
var command = isCommand(event);
var key = event.keyCode;
var value = this.text;
var ss = this.selectionStart;
var se = this.selectionEnd;
if ([
9, 91, 93, 16, 17, 18,
20, // caps lock
13, // Enter (handled by keydown)
112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, // F[0-12]
27 // Esc
].indexOf(key) > -1) {
return;
}
if (key != 37 && key != 39) {
if (!/\n$/.test(value)) {
this.innerHTML = editor.innerHTML + '\n';
}
if (ss != null || se !== null) {
this.setSelectionRange(ss, se);
}
}
}.bind(editor);
}
function isCommand (event) {
return event.metaKey || event.ctrlKey;
}
}());
/**
* IGNORE THESE PROPERTIES, JUST SCROLL DOWN
*/
(function(){
Object.defineProperty(HTMLPreElement.prototype, 'selectionStart', {
get: function() {
var selection = getSelection();
if(selection.rangeCount) {
var range = selection.getRangeAt(0),
element = range.startContainer,
container = element,
offset = range.startOffset;
if(!(this.compareDocumentPosition(element) & 0x10)) {
return 0;
}
do {
while(element = element.previousSibling) {
if(element.textContent) {
offset += element.textContent.length;
}
}
element = container = container.parentNode;
} while(element && element != this);
return offset;
}
else {
return 0;
}
},
enumerable: true,
configurable: true
});
Object.defineProperty(HTMLPreElement.prototype, 'selectionEnd', {
get: function() {
var selection = getSelection();
if(selection.rangeCount) {
return this.selectionStart + (selection.getRangeAt(0) + '').length;
}
else {
return 0;
}
},
enumerable: true,
configurable: true
});
Object.defineProperty(HTMLPreElement.prototype, 'text', {
get: function (el) {
var node;
var el = el || this;
var nodeType = el.nodeType;
var i = 0;
var text = '';
// ELEMENT_NODE || DOCUMENT_NODE || DOCUMENT_FRAGMENT_NODE
if (nodeType === 1 || nodeType === 9 || nodeType === 11) {
if (typeof el.textContent === 'string') {
return el.textContent;
}
else {
// textContent can be null, in which case we walk the element tree
for (el = el.firstChild; el; el = el.nextSibling) {
text += _getText(el);
}
}
}
else if (nodeType === 3 || nodeType === 4) {
return el.nodeValue;
}
else {
for (; (node = el[i]); i++) {
text += _getText(node);
}
}
return text;
},
enumerable: true,
configurable: true
});
HTMLPreElement.prototype.setSelectionRange = function(ss, se) {
var range = document.createRange(),
offset = findOffset(this, ss);
range.setStart(offset.element, offset.offset);
if(se && se != ss) {
offset = findOffset(this, se);
}
range.setEnd(offset.element, offset.offset);
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function findOffset(root, ss) {
if(!root) {
return null;
}
var offset = 0,
element = root;
do {
var container = element;
element = element.firstChild;
if(element) {
do {
var len = element.textContent.length;
if(offset <= ss && offset + len > ss) {
break;
}
offset += len;
} while(element = element.nextSibling);
}
if(!element) {
// It's the container's lastChild
break;
}
} while(element && element.hasChildNodes() && element.nodeType != 3);
if(element) {
return {
element: element,
offset: ss - offset
};
}
else if(container) {
element = container;
while(element && element.lastChild) {
element = element.lastChild;
}
if(element.nodeType === 3) {
return {
element: element,
offset: element.textContent.length
};
}
else {
return {
element: element,
offset: 0
};
}
}
return {
element: root,
offset: 0,
error: true
};
}
}());
/**
* IGNORE EVERYTHING ABOVE
* START READING FROM HERE <====================================
*
* Testing undo + [[Set]] text methods
*
* For most part, you can assume Chrome === Safari
*/
/**
* Microsoft API
*
* Chrome: undo works fine
* Firefox: shit goes loose
* Opera: ???
* IE8: ???
* IE9: ???
*/
HTMLPreElement.prototype.setInnerText = function(value) {
console.log('setInnerText', this);
this.innerText = value;
};
/**
* W3C API
*
* Chrome: undo does not work
* Firefox: ???
* Opera: ???
* IE8: ???
* IE9: ???
*/
HTMLPreElement.prototype.setTextContent = function(value) {
console.log('setTextContent', this);
this.textContent = value;
};
// Use TextNode but, empty() before appending
/**
* $.text style DOM manipulation
*
* Chrome: undo does not work
* Firefox: ???
* Opera: ???
* IE8: ???
* IE9: ???
*/
HTMLPreElement.prototype.setTextNodeByReplacement = function(value) {
console.log('setTextNodeByReplacement', this);
var textNode = (this.ownerDocument || document).createTextNode(value);
// Empty
while (this.hasChildNodes()) {
this.removeChild(this.lastChild);
}
// Append
this.appendChild(textNode);
};
/**
* Similar to above, but instead of replacing we change the nodeValue
*
* Chrome: undo works
* Firefox: ???
* Opera: ???
* IE8: ???
* IE9: ???
*/
HTMLPreElement.prototype.setTextNode = function(value) {
console.log('setTextNode', this);
var textNode = this.firstChild;
textNode.nodeValue = value;
};
<!doctype html>
<html lang="en">
<head>
<title>contentEditable vs. Undo</title>
</head>
<style>
pre {
border: 1px solid #222;
width: 100%;
height: 130px;
white-space: pre;
position: relative;
}
/* Let's identify our editors */
pre:after {
position: absolute;
top: 0;
right: 0;
content: attr(id);
}
</style>
<body>
<pre id="editor-setInnerText" contenteditable></pre>
<pre id="editor-setTextContent" contenteditable></pre>
<pre id="editor-setTextNodeByReplacement" contenteditable></pre>
<pre id="editor-setTextNode" contenteditable></pre>
<script src="HTMLPreElement.extensions.js"></script>
<script src="ContentEditable.tests.js"></script>
</body>
</html>
{
"name": "dom-transaction-undo-test",
"version": "0.0.1",
"engines": {
"node": "0.x",
"npm": "1.x"
},
"dependencies": {
"statik": ">= 1.0.0"
},
"main": "server.js",
"subdomain": "dom-transaction-undo-test",
"scripts": {
"start": "server.js"
}
}
web: node server.js
var statik = require('statik');
var server = statik.createServer('.');
server.listen();
console.log('Server online at: http://localhost:%d', process.env.PORT || 1203);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment