/**
* Selection.js
*
* Copyright, Moxiecode Systems AB
* Released under LGPL License.
*
* License: http://www.tinymce.com/license
* Contributing: http://www.tinymce.com/contributing
*/
/**
* This class handles text and control selection it's an crossbrowser utility class.
* Consult the TinyMCE Wiki API for more details and examples on how to use this class.
*
* @class tinymce.dom.Selection
* @example
* // Getting the currently selected node for the active editor
* alert(tinymce.activeEditor.selection.getNode().nodeName);
*/
define("tinymce/dom/Selection", [
"tinymce/dom/TreeWalker",
"tinymce/dom/TridentSelection",
"tinymce/dom/ControlSelection",
"tinymce/dom/RangeUtils",
"tinymce/Env",
"tinymce/util/Tools"
], function(TreeWalker, TridentSelection, ControlSelection, RangeUtils, Env, Tools) {
var each = Tools.each, grep = Tools.grep, trim = Tools.trim;
var isIE = Env.ie, isOpera = Env.opera;
/**
* Constructs a new selection instance.
*
* @constructor
* @method Selection
* @param {tinymce.dom.DOMUtils} dom DOMUtils object reference.
* @param {Window} win Window to bind the selection object to.
* @param {tinymce.dom.Serializer} serializer DOM serialization class to use for getContent.
*/
function Selection(dom, win, serializer, editor) {
var self = this;
self.dom = dom;
self.win = win;
self.serializer = serializer;
self.editor = editor;
self.controlSelection = new ControlSelection(self, editor);
// No W3C Range support
if (!self.win.getSelection) {
self.tridentSel = new TridentSelection(self);
}
}
Selection.prototype = {
/**
* Move the selection cursor range to the specified node and offset.
* If there is no node specified it will move it to the first suitable location within the body.
*
* @method setCursorLocation
* @param {Node} node Optional node to put the cursor in.
* @param {Number} offset Optional offset from the start of the node to put the cursor at.
*/
setCursorLocation: function(node, offset) {
var self = this, rng = self.dom.createRng();
if (!node) {
self._moveEndPoint(rng, self.editor.getBody(), true);
self.setRng(rng);
} else {
rng.setStart(node, offset);
rng.setEnd(node, offset);
self.setRng(rng);
self.collapse(false);
}
},
/**
* Returns the selected contents using the DOM serializer passed in to this class.
*
* @method getContent
* @param {Object} s Optional settings class with for example output format text or html.
* @return {String} Selected contents in for example HTML format.
* @example
* // Alerts the currently selected contents
* alert(tinymce.activeEditor.selection.getContent());
*
* // Alerts the currently selected contents as plain text
* alert(tinymce.activeEditor.selection.getContent({format: 'text'}));
*/
getContent: function(args) {
var self = this, rng = self.getRng(), tmpElm = self.dom.create("body");
var se = self.getSel(), whiteSpaceBefore, whiteSpaceAfter, fragment;
args = args || {};
whiteSpaceBefore = whiteSpaceAfter = '';
args.get = true;
args.format = args.format || 'html';
args.selection = true;
self.editor.fire('BeforeGetContent', args);
if (args.format == 'text') {
return self.isCollapsed() ? '' : (rng.text || (se.toString ? se.toString() : ''));
}
if (rng.cloneContents) {
fragment = rng.cloneContents();
if (fragment) {
tmpElm.appendChild(fragment);
}
} else if (rng.item !== undefined || rng.htmlText !== undefined) {
// IE will produce invalid markup if elements are present that
// it doesn't understand like custom elements or HTML5 elements.
// Adding a BR in front of the contents and then remoiving it seems to fix it though.
tmpElm.innerHTML = '
' + (rng.item ? rng.item(0).outerHTML : rng.htmlText);
tmpElm.removeChild(tmpElm.firstChild);
} else {
tmpElm.innerHTML = rng.toString();
}
// Keep whitespace before and after
if (/^\s/.test(tmpElm.innerHTML)) {
whiteSpaceBefore = ' ';
}
if (/\s+$/.test(tmpElm.innerHTML)) {
whiteSpaceAfter = ' ';
}
args.getInner = true;
args.content = self.isCollapsed() ? '' : whiteSpaceBefore + self.serializer.serialize(tmpElm, args) + whiteSpaceAfter;
self.editor.fire('GetContent', args);
return args.content;
},
/**
* Sets the current selection to the specified content. If any contents is selected it will be replaced
* with the contents passed in to this function. If there is no selection the contents will be inserted
* where the caret is placed in the editor/page.
*
* @method setContent
* @param {String} content HTML contents to set could also be other formats depending on settings.
* @param {Object} args Optional settings object with for example data format.
* @example
* // Inserts some HTML contents at the current selection
* tinymce.activeEditor.selection.setContent('Some contents');
*/
setContent: function(content, args) {
var self = this, rng = self.getRng(), caretNode, doc = self.win.document, frag, temp;
args = args || {format: 'html'};
args.set = true;
args.selection = true;
content = args.content = content;
// Dispatch before set content event
if (!args.no_events) {
self.editor.fire('BeforeSetContent', args);
}
content = args.content;
if (rng.insertNode) {
// Make caret marker since insertNode places the caret in the beginning of text after insert
content += '_';
// Delete and insert new node
if (rng.startContainer == doc && rng.endContainer == doc) {
// WebKit will fail if the body is empty since the range is then invalid and it can't insert contents
doc.body.innerHTML = content;
} else {
rng.deleteContents();
if (doc.body.childNodes.length === 0) {
doc.body.innerHTML = content;
} else {
// createContextualFragment doesn't exists in IE 9 DOMRanges
if (rng.createContextualFragment) {
rng.insertNode(rng.createContextualFragment(content));
} else {
// Fake createContextualFragment call in IE 9
frag = doc.createDocumentFragment();
temp = doc.createElement('div');
frag.appendChild(temp);
temp.outerHTML = content;
rng.insertNode(frag);
}
}
}
// Move to caret marker
caretNode = self.dom.get('__caret');
// Make sure we wrap it compleatly, Opera fails with a simple select call
rng = doc.createRange();
rng.setStartBefore(caretNode);
rng.setEndBefore(caretNode);
self.setRng(rng);
// Remove the caret position
self.dom.remove('__caret');
try {
self.setRng(rng);
} catch (ex) {
// Might fail on Opera for some odd reason
}
} else {
if (rng.item) {
// Delete content and get caret text selection
doc.execCommand('Delete', false, null);
rng = self.getRng();
}
// Explorer removes spaces from the beginning of pasted contents
if (/^\s+/.test(content)) {
rng.pasteHTML('_' + content);
self.dom.remove('__mce_tmp');
} else {
rng.pasteHTML(content);
}
}
// Dispatch set content event
if (!args.no_events) {
self.editor.fire('SetContent', args);
}
},
/**
* Returns the start element of a selection range. If the start is in a text
* node the parent element will be returned.
*
* @method getStart
* @return {Element} Start element of selection range.
*/
getStart: function() {
var self = this, rng = self.getRng(), startElement, parentElement, checkRng, node;
if (rng.duplicate || rng.item) {
// Control selection, return first item
if (rng.item) {
return rng.item(0);
}
// Get start element
checkRng = rng.duplicate();
checkRng.collapse(1);
startElement = checkRng.parentElement();
if (startElement.ownerDocument !== self.dom.doc) {
startElement = self.dom.getRoot();
}
// Check if range parent is inside the start element, then return the inner parent element
// This will fix issues when a single element is selected, IE would otherwise return the wrong start element
parentElement = node = rng.parentElement();
while ((node = node.parentNode)) {
if (node == startElement) {
startElement = parentElement;
break;
}
}
return startElement;
} else {
startElement = rng.startContainer;
if (startElement.nodeType == 1 && startElement.hasChildNodes()) {
startElement = startElement.childNodes[Math.min(startElement.childNodes.length - 1, rng.startOffset)];
}
if (startElement && startElement.nodeType == 3) {
return startElement.parentNode;
}
return startElement;
}
},
/**
* Returns the end element of a selection range. If the end is in a text
* node the parent element will be returned.
*
* @method getEnd
* @return {Element} End element of selection range.
*/
getEnd: function() {
var self = this, rng = self.getRng(), endElement, endOffset;
if (rng.duplicate || rng.item) {
if (rng.item) {
return rng.item(0);
}
rng = rng.duplicate();
rng.collapse(0);
endElement = rng.parentElement();
if (endElement.ownerDocument !== self.dom.doc) {
endElement = self.dom.getRoot();
}
if (endElement && endElement.nodeName == 'BODY') {
return endElement.lastChild || endElement;
}
return endElement;
} else {
endElement = rng.endContainer;
endOffset = rng.endOffset;
if (endElement.nodeType == 1 && endElement.hasChildNodes()) {
endElement = endElement.childNodes[endOffset > 0 ? endOffset - 1 : endOffset];
}
if (endElement && endElement.nodeType == 3) {
return endElement.parentNode;
}
return endElement;
}
},
/**
* Returns a bookmark location for the current selection. This bookmark object
* can then be used to restore the selection after some content modification to the document.
*
* @method getBookmark
* @param {Number} type Optional state if the bookmark should be simple or not. Default is complex.
* @param {Boolean} normalized Optional state that enables you to get a position that it would be after normalization.
* @return {Object} Bookmark object, use moveToBookmark with this object to restore the selection.
* @example
* // Stores a bookmark of the current selection
* var bm = tinymce.activeEditor.selection.getBookmark();
*
* tinymce.activeEditor.setContent(tinymce.activeEditor.getContent() + 'Some new content');
*
* // Restore the selection bookmark
* tinymce.activeEditor.selection.moveToBookmark(bm);
*/
getBookmark: function(type, normalized) {
var self = this, dom = self.dom, rng, rng2, id, collapsed, name, element, chr = '', styles;
function findIndex(name, element) {
var index = 0;
each(dom.select(name), function(node, i) {
if (node == element) {
index = i;
}
});
return index;
}
function normalizeTableCellSelection(rng) {
function moveEndPoint(start) {
var container, offset, childNodes, prefix = start ? 'start' : 'end';
container = rng[prefix + 'Container'];
offset = rng[prefix + 'Offset'];
if (container.nodeType == 1 && container.nodeName == "TR") {
childNodes = container.childNodes;
container = childNodes[Math.min(start ? offset : offset - 1, childNodes.length - 1)];
if (container) {
offset = start ? 0 : container.childNodes.length;
rng['set' + (start ? 'Start' : 'End')](container, offset);
}
}
}
moveEndPoint(true);
moveEndPoint();
return rng;
}
function getLocation() {
var rng = self.getRng(true), root = dom.getRoot(), bookmark = {};
function getPoint(rng, start) {
var container = rng[start ? 'startContainer' : 'endContainer'],
offset = rng[start ? 'startOffset' : 'endOffset'], point = [], node, childNodes, after = 0;
if (container.nodeType == 3) {
if (normalized) {
for (node = container.previousSibling; node && node.nodeType == 3; node = node.previousSibling) {
offset += node.nodeValue.length;
}
}
point.push(offset);
} else {
childNodes = container.childNodes;
if (offset >= childNodes.length && childNodes.length) {
after = 1;
offset = Math.max(0, childNodes.length - 1);
}
point.push(self.dom.nodeIndex(childNodes[offset], normalized) + after);
}
for (; container && container != root; container = container.parentNode) {
point.push(self.dom.nodeIndex(container, normalized));
}
return point;
}
bookmark.start = getPoint(rng, true);
if (!self.isCollapsed()) {
bookmark.end = getPoint(rng);
}
return bookmark;
}
if (type == 2) {
element = self.getNode();
name = element ? element.nodeName : null;
if (name == 'IMG') {
return {name: name, index: findIndex(name, element)};
}
if (self.tridentSel) {
return self.tridentSel.getBookmark(type);
}
return getLocation();
}
// Handle simple range
if (type) {
return {rng: self.getRng()};
}
rng = self.getRng();
id = dom.uniqueId();
collapsed = self.isCollapsed();
styles = 'overflow:hidden;line-height:0px';
// Explorer method
if (rng.duplicate || rng.item) {
// Text selection
if (!rng.item) {
rng2 = rng.duplicate();
try {
// Insert start marker
rng.collapse();
rng.pasteHTML('' + chr + '');
// Insert end marker
if (!collapsed) {
rng2.collapse(false);
// Detect the empty space after block elements in IE and move the
// end back one character
]
rng.moveToElementText(rng2.parentElement()); if (rng.compareEndPoints('StartToEnd', rng2) === 0) { rng2.move('character', -1); } rng2.pasteHTML('' + chr + ''); } } catch (ex) { // IE might throw unspecified error so lets ignore it return null; } } else { // Control selection element = rng.item(0); name = element.nodeName; return {name: name, index: findIndex(name, element)}; } } else { element = self.getNode(); name = element.nodeName; if (name == 'IMG') { return {name: name, index: findIndex(name, element)}; } // W3C method rng2 = normalizeTableCellSelection(rng.cloneRange()); // Insert end marker if (!collapsed) { rng2.collapse(false); rng2.insertNode(dom.create('span', {'data-mce-type': "bookmark", id: id + '_end', style: styles}, chr)); } rng = normalizeTableCellSelection(rng); rng.collapse(true); rng.insertNode(dom.create('span', {'data-mce-type': "bookmark", id: id + '_start', style: styles}, chr)); } self.moveToBookmark({id: id, keep: 1}); return {id: id}; }, /** * Restores the selection to the specified bookmark. * * @method moveToBookmark * @param {Object} bookmark Bookmark to restore selection from. * @return {Boolean} true/false if it was successful or not. * @example * // Stores a bookmark of the current selection * var bm = tinymce.activeEditor.selection.getBookmark(); * * tinymce.activeEditor.setContent(tinymce.activeEditor.getContent() + 'Some new content'); * * // Restore the selection bookmark * tinymce.activeEditor.selection.moveToBookmark(bm); */ moveToBookmark: function(bookmark) { var self = this, dom = self.dom, rng, root, startContainer, endContainer, startOffset, endOffset; function setEndPoint(start) { var point = bookmark[start ? 'start' : 'end'], i, node, offset, children; if (point) { offset = point[0]; // Find container node for (node = root, i = point.length - 1; i >= 1; i--) { children = node.childNodes; if (point[i] > children.length - 1) { return; } node = children[point[i]]; } // Move text offset to best suitable location if (node.nodeType === 3) { offset = Math.min(point[0], node.nodeValue.length); } // Move element offset to best suitable location if (node.nodeType === 1) { offset = Math.min(point[0], node.childNodes.length); } // Set offset within container node if (start) { rng.setStart(node, offset); } else { rng.setEnd(node, offset); } } return true; } function restoreEndPoint(suffix) { var marker = dom.get(bookmark.id + '_' + suffix), node, idx, next, prev, keep = bookmark.keep; if (marker) { node = marker.parentNode; if (suffix == 'start') { if (!keep) { idx = dom.nodeIndex(marker); } else { node = marker.firstChild; idx = 1; } startContainer = endContainer = node; startOffset = endOffset = idx; } else { if (!keep) { idx = dom.nodeIndex(marker); } else { node = marker.firstChild; idx = 1; } endContainer = node; endOffset = idx; } if (!keep) { prev = marker.previousSibling; next = marker.nextSibling; // Remove all marker text nodes each(grep(marker.childNodes), function(node) { if (node.nodeType == 3) { node.nodeValue = node.nodeValue.replace(/\uFEFF/g, ''); } }); // Remove marker but keep children if for example contents where inserted into the marker // Also remove duplicated instances of the marker for example by a // split operation or by WebKit auto split on paste feature while ((marker = dom.get(bookmark.id + '_' + suffix))) { dom.remove(marker, 1); } // If siblings are text nodes then merge them unless it's Opera since it some how removes the node // and we are sniffing since adding a lot of detection code for a browser with 3% of the market // isn't worth the effort. Sorry, Opera but it's just a fact if (prev && next && prev.nodeType == next.nodeType && prev.nodeType == 3 && !isOpera) { idx = prev.nodeValue.length; prev.appendData(next.nodeValue); dom.remove(next); if (suffix == 'start') { startContainer = endContainer = prev; startOffset = endOffset = idx; } else { endContainer = prev; endOffset = idx; } } } } } function addBogus(node) { // Adds a bogus BR element for empty block elements if (dom.isBlock(node) && !node.innerHTML && !isIE) { node.innerHTML = '