2 Copyright (c) 2010, Yahoo! Inc. All rights reserved.
3 Code licensed under the BSD License:
4 http://developer.yahoo.com/yui/license.html
8 YUI.add('selection', function(Y) {
11 * Wraps some common Selection/Range functionality into a simple object
13 * @submodule selection
16 * Wraps some common Selection/Range functionality into a simple object
22 //TODO This shouldn't be there, Y.Node doesn't normalize getting textnode content.
23 var textContent = 'textContent',
24 INNER_HTML = 'innerHTML',
25 FONT_FAMILY = 'fontFamily';
28 textContent = 'nodeValue';
31 Y.Selection = function(domEvent) {
32 var sel, par, ieNode, nodes, rng, i;
34 if (Y.config.win.getSelection) {
35 sel = Y.config.win.getSelection();
36 } else if (Y.config.doc.selection) {
37 sel = Y.config.doc.selection.createRange();
39 this._selection = sel;
42 this.isCollapsed = (sel.compareEndPoints('StartToEnd', sel)) ? false : true;
43 if (this.isCollapsed) {
44 this.anchorNode = this.focusNode = Y.one(sel.parentElement());
47 ieNode = Y.config.doc.elementFromPoint(domEvent.clientX, domEvent.clientY);
50 par = sel.parentElement();
51 nodes = par.childNodes;
52 rng = sel.duplicate();
54 for (i = 0; i < nodes.length; i++) {
55 //This causes IE to not allow a selection on a doubleclick
56 //rng.select(nodes[i]);
57 if (rng.inRange(sel)) {
68 if (ieNode.nodeType !== 3) {
69 if (ieNode.firstChild) {
70 ieNode = ieNode.firstChild;
72 if (ieNode && ieNode.tagName && ieNode.tagName.toLowerCase() === 'body') {
73 if (ieNode.firstChild) {
74 ieNode = ieNode.firstChild;
78 this.anchorNode = this.focusNode = Y.Selection.resolve(ieNode);
80 this.anchorOffset = this.focusOffset = (this.anchorNode.nodeValue) ? this.anchorNode.nodeValue.length : 0 ;
82 this.anchorTextNode = this.focusTextNode = Y.one(ieNode);
87 //This helps IE deal with a selection and nodeChange events
89 var n = Y.Node.create(sel.htmlText);
92 this.anchorNode = this.focusNode = Y.one('#' + id);
94 n = n.get('childNodes');
95 this.anchorNode = this.focusNode = n.item(0);
103 this.isCollapsed = sel.isCollapsed;
104 this.anchorNode = Y.Selection.resolve(sel.anchorNode);
105 this.focusNode = Y.Selection.resolve(sel.focusNode);
106 this.anchorOffset = sel.anchorOffset;
107 this.focusOffset = sel.focusOffset;
109 this.anchorTextNode = Y.one(sel.anchorNode);
110 this.focusTextNode = Y.one(sel.focusNode);
112 if (Y.Lang.isString(sel.text)) {
113 this.text = sel.text;
116 this.text = sel.toString();
124 * Performs a prefilter on all nodes in the editor. Looks for nodes with a style: fontFamily or font face
125 * It then creates a dynamic class assigns it and removed the property. This is so that we don't lose
126 * the fontFamily when selecting nodes.
130 Y.Selection.filter = function(blocks) {
131 var startTime = (new Date()).getTime();
133 var nodes = Y.all(Y.Selection.ALL),
134 baseNodes = Y.all('strong,em'),
136 hrs = doc.getElementsByTagName('hr'),
137 classNames = {}, cssString = '',
140 var startTime1 = (new Date()).getTime();
141 nodes.each(function(n) {
142 var raw = Y.Node.getDOMNode(n);
143 if (raw.style[FONT_FAMILY]) {
144 classNames['.' + n._yuid] = raw.style[FONT_FAMILY];
146 raw.style[FONT_FAMILY] = 'inherit';
148 raw.removeAttribute('face');
149 if (raw.getAttribute('style') === '') {
150 raw.removeAttribute('style');
153 if (raw.getAttribute('style')) {
154 if (raw.getAttribute('style').toLowerCase() === 'font-family: ') {
155 raw.removeAttribute('style');
160 if (n.getStyle(FONT_FAMILY)) {
161 classNames['.' + n._yuid] = n.getStyle(FONT_FAMILY);
163 n.removeAttribute('face');
164 n.setStyle(FONT_FAMILY, '');
165 if (n.getAttribute('style') === '') {
166 n.removeAttribute('style');
169 if (n.getAttribute('style').toLowerCase() === 'font-family: ') {
170 n.removeAttribute('style');
175 var endTime1 = (new Date()).getTime();
177 Y.all('.hr').addClass('yui-skip').addClass('yui-non');
179 Y.each(hrs, function(hr) {
180 var el = doc.createElement('div');
181 el.className = 'hr yui-non yui-skip';
183 el.setAttribute('readonly', true);
184 el.setAttribute('contenteditable', false); //Keep it from being Edited
186 hr.parentNode.replaceChild(el, hr);
188 //Had to move to inline style. writes for ie's < 8. They don't render el.setAttribute('style');
190 s.border = '1px solid #ccc';
194 s.marginBottom = '5px';
195 s.marginLeft = '0px';
196 s.marginRight = '0px';
201 Y.each(classNames, function(v, k) {
202 cssString += k + ' { font-family: ' + v.replace(/"/gi, '') + '; }';
204 Y.StyleSheet(cssString, 'editor');
207 //Not sure about this one?
208 baseNodes.each(function(n, k) {
209 var t = n.get('tagName').toLowerCase(),
211 if (t === 'strong') {
214 Y.Selection.prototype._swap(baseNodes.item(k), newTag);
217 //Filter out all the empty UL/OL's
219 ls.each(function(v, k) {
220 var lis = v.all('li');
227 Y.Selection.filterBlocks();
229 var endTime = (new Date()).getTime();
233 * Method attempts to replace all "orphined" text nodes in the main body by wrapping them with a <p>. Called from filter.
235 * @method filterBlocks
237 Y.Selection.filterBlocks = function() {
238 var startTime = (new Date()).getTime();
239 var childs = Y.config.doc.body.childNodes, i, node, wrapped = false, doit = true,
240 sel, single, br, divs, spans, c, s;
243 for (i = 0; i < childs.length; i++) {
244 node = Y.one(childs[i]);
245 if (!node.test(Y.Selection.BLOCKS)) {
247 if (childs[i].nodeType == 3) {
248 c = childs[i][textContent].match(Y.Selection.REG_CHAR);
249 s = childs[i][textContent].match(Y.Selection.REG_NON);
250 if (c === null && s) {
259 wrapped.push(childs[i]);
262 wrapped = Y.Selection._wrapBlock(wrapped);
265 wrapped = Y.Selection._wrapBlock(wrapped);
268 single = Y.all(Y.Selection.DEFAULT_BLOCK_TAG);
269 if (single.size() === 1) {
270 br = single.item(0).all('br');
271 if (br.size() === 1) {
272 if (!br.item(0).test('.yui-cursor')) {
275 var html = single.item(0).get('innerHTML');
276 if (html === '' || html === ' ') {
277 single.set('innerHTML', Y.Selection.CURSOR);
278 sel = new Y.Selection();
279 sel.focusCursor(true, true);
283 single.each(function(p) {
284 var html = p.get('innerHTML');
293 divs = Y.all('div, p');
294 divs.each(function(d) {
295 if (d.hasClass('yui-non')) {
298 var html = d.get('innerHTML');
302 if (d.get('childNodes').size() == 1) {
303 if (d.ancestor('p')) {
304 d.replace(d.get('firstChild'));
310 /** Removed this, as it was causing Pasting to be funky in Safari
311 spans = Y.all('.Apple-style-span, .apple-style-span');
312 spans.each(function(s) {
313 s.setAttribute('style', '');
319 var endTime = (new Date()).getTime();
323 * Regular Expression to determine if a string has a character in it
327 Y.Selection.REG_CHAR = /[a-zA-Z-0-9_]/gi;
330 * Regular Expression to determine if a string has a non-character in it
334 Y.Selection.REG_NON = /[\s\S|\n|\t]/gi;
337 * Regular Expression to remove all HTML from a string
339 * @property REG_NOHTML
341 Y.Selection.REG_NOHTML = /<\S[^><]*>/g;
345 * Wraps an array of elements in a Block level tag
350 Y.Selection._wrapBlock = function(wrapped) {
352 var newChild = Y.Node.create('<' + Y.Selection.DEFAULT_BLOCK_TAG + '></' + Y.Selection.DEFAULT_BLOCK_TAG + '>'),
353 firstChild = Y.one(wrapped[0]), i;
355 for (i = 1; i < wrapped.length; i++) {
356 newChild.append(wrapped[i]);
358 firstChild.replace(newChild);
359 newChild.prepend(firstChild);
365 * Undoes what filter does enough to return the HTML from the Editor, then re-applies the filter.
368 * @return {String} The filtered HTML
370 Y.Selection.unfilter = function() {
371 var nodes = Y.all('body [class]'),
372 html = '', nons, ids,
373 body = Y.one('body');
376 nodes.each(function(n) {
377 if (n.hasClass(n._yuid)) {
379 n.setStyle(FONT_FAMILY, n.getStyle(FONT_FAMILY));
380 n.removeClass(n._yuid);
381 if (n.getAttribute('class') === '') {
382 n.removeAttribute('class');
387 nons = Y.all('.yui-non');
388 nons.each(function(n) {
389 if (!n.hasClass('yui-skip') && n.get('innerHTML') === '') {
392 n.removeClass('yui-non').removeClass('yui-skip');
396 ids = Y.all('body [id]');
397 ids.each(function(n) {
398 if (n.get('id').indexOf('yui_3_') === 0) {
399 n.removeAttribute('id');
400 n.removeAttribute('_yuid');
405 html = body.get('innerHTML');
408 Y.all('.hr').addClass('yui-skip').addClass('yui-non');
411 nodes.each(function(n) {
413 n.setStyle(FONT_FAMILY, '');
414 if (n.getAttribute('style') === '') {
415 n.removeAttribute('style');
423 * Resolve a node from the selection object and return a Node instance
426 * @param {HTMLElement} n The HTMLElement to resolve. Might be a TextNode, gives parentNode.
427 * @return {Node} The Resolved node
429 Y.Selection.resolve = function(n) {
430 if (n && n.nodeType === 3) {
431 //Adding a try/catch here because in rare occasions IE will
432 //Throw a error accessing the parentNode of a stranded text node.
433 //In the case of Ctrl+Z (Undo)
444 * Returns the innerHTML of a node with all HTML tags removed.
447 * @param {Node} node The Node instance to remove the HTML from
448 * @return {String} The string of text
450 Y.Selection.getText = function(node) {
451 var txt = node.get('innerHTML').replace(Y.Selection.REG_NOHTML, '');
452 //Clean out the cursor subs to see if the Node is empty
453 txt = txt.replace('<span><br></span>', '').replace('<br>', '');
457 //Y.Selection.DEFAULT_BLOCK_TAG = 'div';
458 Y.Selection.DEFAULT_BLOCK_TAG = 'p';
461 * The selector to use when looking for Nodes to cache the value of: [style],font[face]
465 Y.Selection.ALL = '[style],font[face]';
468 * The selector to use when looking for block level items.
472 Y.Selection.BLOCKS = 'p,div,ul,ol,table,style';
474 * The temporary fontname applied to a selection to retrieve their values: yui-tmp
478 Y.Selection.TMP = 'yui-tmp';
480 * The default tag to use when creating elements: span
482 * @property DEFAULT_TAG
484 Y.Selection.DEFAULT_TAG = 'span';
487 * The id of the outer cursor wrapper
489 * @property DEFAULT_TAG
491 Y.Selection.CURID = 'yui-cursor';
494 * The id used to wrap the inner space of the cursor position
496 * @property CUR_WRAPID
498 Y.Selection.CUR_WRAPID = 'yui-cursor-wrapper';
501 * The default HTML used to focus the cursor..
505 Y.Selection.CURSOR = '<span><br class="yui-cursor"></span>';
507 Y.Selection.hasCursor = function() {
508 var cur = Y.all('#' + Y.Selection.CUR_WRAPID);
513 * Called from Editor keydown to remove the "extra" space before the cursor.
515 * @method cleanCursor
517 Y.Selection.cleanCursor = function() {
518 var cur, sel = 'br.yui-cursor';
521 cur.each(function(b) {
522 var c = b.get('parentNode.parentNode.childNodes'), html;
526 html = Y.Selection.getText(c.item(0));
534 var cur = Y.all('#' + Y.Selection.CUR_WRAPID);
536 cur.each(function(c) {
537 var html = c.get('innerHTML');
538 if (html == ' ' || html == '<br>') {
539 if (c.previous() || c.next()) {
548 Y.Selection.prototype = {
556 * Flag to show if the range is collapsed or not
557 * @property isCollapsed
562 * A Node instance of the parentNode of the anchorNode of the range
563 * @property anchorNode
568 * The offset from the range object
569 * @property anchorOffset
574 * A Node instance of the actual textNode of the range.
575 * @property anchorTextNode
578 anchorTextNode: null,
580 * A Node instance of the parentNode of the focusNode of the range
581 * @property focusNode
586 * The offset from the range object
587 * @property focusOffset
592 * A Node instance of the actual textNode of the range.
593 * @property focusTextNode
598 * The actual Selection/Range object
599 * @property _selection
604 * Wrap an element, with another element
607 * @param {HTMLElement} n The node to wrap
608 * @param {String} tag The tag to use when creating the new element.
609 * @return {HTMLElement} The wrapped node
611 _wrap: function(n, tag) {
612 var tmp = Y.Node.create('<' + tag + '></' + tag + '>');
613 tmp.set(INNER_HTML, n.get(INNER_HTML));
614 n.set(INNER_HTML, '');
616 return Y.Node.getDOMNode(tmp);
619 * Swap an element, with another element
622 * @param {HTMLElement} n The node to swap
623 * @param {String} tag The tag to use when creating the new element.
624 * @return {HTMLElement} The new node
626 _swap: function(n, tag) {
627 var tmp = Y.Node.create('<' + tag + '></' + tag + '>');
628 tmp.set(INNER_HTML, n.get(INNER_HTML));
630 return Y.Node.getDOMNode(tmp);
633 * Get all the nodes in the current selection. This method will actually perform a filter first.
634 * Then it calls doc.execCommand('fontname', null, 'yui-tmp') to touch all nodes in the selection.
635 * The it compiles a list of all nodes affected by the execCommand and builds a NodeList to return.
636 * @method getSelected
637 * @return {NodeList} A NodeList of all items in the selection.
639 getSelected: function() {
640 Y.Selection.filter();
641 Y.config.doc.execCommand('fontname', null, Y.Selection.TMP);
642 var nodes = Y.all(Y.Selection.ALL),
645 nodes.each(function(n, k) {
646 if (n.getStyle(FONT_FAMILY) == Y.Selection.TMP) {
647 n.setStyle(FONT_FAMILY, '');
648 n.removeAttribute('face');
649 if (n.getAttribute('style') === '') {
650 n.removeAttribute('style');
652 if (!n.test('body')) {
653 items.push(Y.Node.getDOMNode(nodes.item(k)));
660 * Insert HTML at the current cursor position and return a Node instance of the newly inserted element.
661 * @method insertContent
662 * @param {String} html The HTML to insert.
663 * @return {Node} The inserted Node.
665 insertContent: function(html) {
666 return this.insertAtCursor(html, this.anchorTextNode, this.anchorOffset, true);
669 * Insert HTML at the current cursor position, this method gives you control over the text node to insert into and the offset where to put it.
670 * @method insertAtCursor
671 * @param {String} html The HTML to insert.
672 * @param {Node} node The text node to break when inserting.
673 * @param {Number} offset The left offset of the text node to break and insert the new content.
674 * @param {Boolean} collapse Should the range be collapsed after insertion. default: false
675 * @return {Node} The inserted Node.
677 insertAtCursor: function(html, node, offset, collapse) {
678 var cur = Y.Node.create('<' + Y.Selection.DEFAULT_TAG + ' class="yui-non"></' + Y.Selection.DEFAULT_TAG + '>'),
679 inHTML, txt, txt2, newNode, range = this.createRange(), b;
681 if (node && node.test('body')) {
682 b = Y.Node.create('<span></span>');
688 if (range.pasteHTML) {
689 newNode = Y.Node.create(html);
691 range.pasteHTML('<span id="rte-insert"></span>');
693 inHTML = Y.one('#rte-insert');
695 inHTML.set('id', '');
696 inHTML.replace(newNode);
699 Y.on('available', function() {
700 inHTML.set('id', '');
701 inHTML.replace(newNode);
705 //TODO using Y.Node.create here throws warnings & strips first white space character
706 //txt = Y.one(Y.Node.create(inHTML.substr(0, offset)));
707 //txt2 = Y.one(Y.Node.create(inHTML.substr(offset)));
709 inHTML = node.get(textContent);
711 txt = Y.one(Y.config.doc.createTextNode(inHTML.substr(0, offset)));
712 txt2 = Y.one(Y.config.doc.createTextNode(inHTML.substr(offset)));
714 node.replace(txt, node);
715 newNode = Y.Node.create(html);
716 if (newNode.get('nodeType') === 11) {
717 b = Y.Node.create('<span></span>');
721 txt.insert(newNode, 'after');
722 //if (txt2 && txt2.get('length')) {
724 newNode.insert(cur, 'after');
725 cur.insert(txt2, 'after');
726 this.selectNode(cur, collapse);
729 if (node.get('nodeType') === 3) {
730 node = node.get('parentNode');
732 newNode = Y.Node.create(html);
733 html = node.get('innerHTML').replace(/\n/gi, '');
734 if (html === '' || html === '<br>') {
735 node.append(newNode);
737 if (newNode.get('parentNode')) {
738 node.insert(newNode, 'before');
740 Y.one('body').prepend(newNode);
743 if (node.get('firstChild').test('br')) {
744 node.get('firstChild').remove();
751 * Get all elements inside a selection and wrap them with a new element and return a NodeList of all elements touched.
752 * @method wrapContent
753 * @param {String} tag The tag to wrap all selected items with.
754 * @return {NodeList} A NodeList of all items in the selection.
756 wrapContent: function(tag) {
757 tag = (tag) ? tag : Y.Selection.DEFAULT_TAG;
759 if (!this.isCollapsed) {
760 var items = this.getSelected(),
761 changed = [], range, last, first, range2;
763 items.each(function(n, k) {
764 var t = n.get('tagName').toLowerCase();
766 changed.push(this._swap(items.item(k), tag));
768 changed.push(this._wrap(items.item(k), tag));
772 range = this.createRange();
774 last = changed[changed.length - 1];
775 if (this._selection.removeAllRanges) {
776 range.setStart(changed[0], 0);
777 range.setEnd(last, last.childNodes.length);
778 this._selection.removeAllRanges();
779 this._selection.addRange(range);
781 range.moveToElementText(Y.Node.getDOMNode(first));
782 range2 = this.createRange();
783 range2.moveToElementText(Y.Node.getDOMNode(last));
784 range.setEndPoint('EndToEnd', range2);
788 changed = Y.all(changed);
797 * Find and replace a string inside a text node and replace it with HTML focusing the node after
798 * to allow you to continue to type.
800 * @param {String} se The string to search for.
801 * @param {String} re The string of HTML to replace it with.
802 * @return {Node} The node inserted.
804 replace: function(se,re) {
805 var range = this.createRange(), node, txt, index, newNode;
807 if (range.getBookmark) {
808 index = range.getBookmark();
809 txt = this.anchorNode.get('innerHTML').replace(se, re);
810 this.anchorNode.set('innerHTML', txt);
811 range.moveToBookmark(index);
812 newNode = Y.one(range.parentElement());
814 node = this.anchorTextNode;
815 txt = node.get(textContent);
816 index = txt.indexOf(se);
818 txt = txt.replace(se, '');
819 node.set(textContent, txt);
820 newNode = this.insertAtCursor(re, node, index, true);
828 * @return {Y.Selection}
831 this._selection.removeAllRanges();
835 * Wrapper for the different range creation methods.
836 * @method createRange
837 * @return {RangeObject}
839 createRange: function() {
840 if (Y.config.doc.selection) {
841 return Y.config.doc.selection.createRange();
843 return Y.config.doc.createRange();
847 * Select a Node (hilighting it).
849 * @param {Node} node The node to select
850 * @param {Boolean} collapse Should the range be collapsed after insertion. default: false
852 * @return {Y.Selection}
854 selectNode: function(node, collapse, end) {
859 node = Y.Node.getDOMNode(node);
860 var range = this.createRange();
861 if (range.selectNode) {
862 range.selectNode(node);
863 this._selection.removeAllRanges();
864 this._selection.addRange(range);
867 this._selection.collapse(node, end);
869 this._selection.collapse(node, 0);
873 if (node.nodeType === 3) {
874 node = node.parentNode;
877 range.moveToElementText(node);
880 range.collapse(((end) ? false : true));
887 * Put a placeholder in the DOM at the current cursor position.
891 setCursor: function() {
892 this.removeCursor(false);
893 return this.insertContent(Y.Selection.CURSOR);
896 * Get the placeholder in the DOM at the current cursor position.
900 getCursor: function() {
901 return Y.all('#' + Y.Selection.CURID);
904 * Remove the cursor placeholder from the DOM.
905 * @method removeCursor
906 * @param {Boolean} keep Setting this to true will keep the node, but remove the unique parts that make it the cursor.
909 removeCursor: function(keep) {
910 var cur = this.getCursor();
913 cur.removeAttribute('id');
914 cur.set('innerHTML', '<br class="yui-cursor">');
922 * Gets a stored cursor and focuses it for editing, must be called sometime after setCursor
923 * @method focusCursor
926 focusCursor: function(collapse, end) {
927 if (collapse !== false) {
933 var cur = this.removeCursor(true);
935 cur.each(function(c) {
936 this.selectNode(c, collapse, end);
941 * Generic toString for logging.
945 toString: function() {
946 return 'Selection Object';
951 }, '3.3.0' ,{requires:['node'], skinnable:false});