3
Copyright 2012 Yahoo! Inc. All rights reserved.
4
Licensed under the BSD License.
5
http://yuilibrary.com/license/
7
YUI.add('editor-selection', function(Y) {
10
* Wraps some common Selection/Range functionality into a simple object
11
* @class EditorSelection
14
* @submodule selection
17
//TODO This shouldn't be there, Y.Node doesn't normalize getting textnode content.
18
var textContent = 'textContent',
19
INNER_HTML = 'innerHTML',
20
FONT_FAMILY = 'fontFamily';
23
textContent = 'nodeValue';
26
Y.EditorSelection = function(domEvent) {
27
var sel, par, ieNode, nodes, rng, i;
29
if (Y.config.win.getSelection && (!Y.UA.ie || Y.UA.ie < 9)) {
30
sel = Y.config.win.getSelection();
31
} else if (Y.config.doc.selection) {
32
sel = Y.config.doc.selection.createRange();
34
this._selection = sel;
37
this.isCollapsed = (sel.compareEndPoints('StartToEnd', sel)) ? false : true;
38
if (this.isCollapsed) {
39
this.anchorNode = this.focusNode = Y.one(sel.parentElement());
42
ieNode = Y.config.doc.elementFromPoint(domEvent.clientX, domEvent.clientY);
44
rng = sel.duplicate();
46
par = sel.parentElement();
47
nodes = par.childNodes;
49
for (i = 0; i < nodes.length; i++) {
50
//This causes IE to not allow a selection on a doubleclick
51
//rng.select(nodes[i]);
52
if (rng.inRange(sel)) {
63
if (ieNode.nodeType !== 3) {
64
if (ieNode.firstChild) {
65
ieNode = ieNode.firstChild;
67
if (ieNode && ieNode.tagName && ieNode.tagName.toLowerCase() === 'body') {
68
if (ieNode.firstChild) {
69
ieNode = ieNode.firstChild;
73
this.anchorNode = this.focusNode = Y.EditorSelection.resolve(ieNode);
75
rng.moveToElementText(sel.parentElement());
76
var comp = sel.compareEndPoints('StartToStart', rng),
79
//We are not at the beginning of the selection.
80
//Setting the move to something large, may need to increase it later
81
moved = Math.abs(sel.move('character', -9999));
84
this.anchorOffset = this.focusOffset = moved;
86
this.anchorTextNode = this.focusTextNode = Y.one(ieNode);
91
//This helps IE deal with a selection and nodeChange events
92
if (sel.htmlText && sel.htmlText !== '') {
93
var n = Y.Node.create(sel.htmlText);
94
if (n && n.get('id')) {
96
this.anchorNode = this.focusNode = Y.one('#' + id);
98
n = n.get('childNodes');
99
this.anchorNode = this.focusNode = n.item(0);
107
this.isCollapsed = sel.isCollapsed;
108
this.anchorNode = Y.EditorSelection.resolve(sel.anchorNode);
109
this.focusNode = Y.EditorSelection.resolve(sel.focusNode);
110
this.anchorOffset = sel.anchorOffset;
111
this.focusOffset = sel.focusOffset;
113
this.anchorTextNode = Y.one(sel.anchorNode);
114
this.focusTextNode = Y.one(sel.focusNode);
116
if (Y.Lang.isString(sel.text)) {
117
this.text = sel.text;
120
this.text = sel.toString();
128
* Utility method to remove dead font-family styles from an element.
130
* @method removeFontFamily
132
Y.EditorSelection.removeFontFamily = function(n) {
133
n.removeAttribute('face');
134
var s = n.getAttribute('style').toLowerCase();
135
if (s === '' || (s == 'font-family: ')) {
136
n.removeAttribute('style');
138
if (s.match(Y.EditorSelection.REG_FONTFAMILY)) {
139
s = s.replace(Y.EditorSelection.REG_FONTFAMILY, '');
140
n.setAttribute('style', s);
145
* Performs a prefilter on all nodes in the editor. Looks for nodes with a style: fontFamily or font face
146
* It then creates a dynamic class assigns it and removed the property. This is so that we don't lose
147
* the fontFamily when selecting nodes.
151
Y.EditorSelection.filter = function(blocks) {
152
var startTime = (new Date()).getTime();
154
var nodes = Y.all(Y.EditorSelection.ALL),
155
baseNodes = Y.all('strong,em'),
156
doc = Y.config.doc, hrs,
157
classNames = {}, cssString = '',
160
var startTime1 = (new Date()).getTime();
161
nodes.each(function(n) {
162
var raw = Y.Node.getDOMNode(n);
163
if (raw.style[FONT_FAMILY]) {
164
classNames['.' + n._yuid] = raw.style[FONT_FAMILY];
167
Y.EditorSelection.removeFontFamily(raw);
170
if (n.getStyle(FONT_FAMILY)) {
171
classNames['.' + n._yuid] = n.getStyle(FONT_FAMILY);
173
n.removeAttribute('face');
174
n.setStyle(FONT_FAMILY, '');
175
if (n.getAttribute('style') === '') {
176
n.removeAttribute('style');
179
if (n.getAttribute('style').toLowerCase() === 'font-family: ') {
180
n.removeAttribute('style');
185
var endTime1 = (new Date()).getTime();
187
Y.all('.hr').addClass('yui-skip').addClass('yui-non');
190
hrs = doc.getElementsByTagName('hr');
191
Y.each(hrs, function(hr) {
192
var el = doc.createElement('div');
193
el.className = 'hr yui-non yui-skip';
195
el.setAttribute('readonly', true);
196
el.setAttribute('contenteditable', false); //Keep it from being Edited
198
hr.parentNode.replaceChild(el, hr);
200
//Had to move to inline style. writes for ie's < 8. They don't render el.setAttribute('style');
202
s.border = '1px solid #ccc';
207
s.marginBottom = '5px';
208
s.marginLeft = '0px';
209
s.marginRight = '0px';
215
Y.each(classNames, function(v, k) {
216
cssString += k + ' { font-family: ' + v.replace(/"/gi, '') + '; }';
218
Y.StyleSheet(cssString, 'editor');
221
//Not sure about this one?
222
baseNodes.each(function(n, k) {
223
var t = n.get('tagName').toLowerCase(),
225
if (t === 'strong') {
228
Y.EditorSelection.prototype._swap(baseNodes.item(k), newTag);
231
//Filter out all the empty UL/OL's
233
ls.each(function(v, k) {
234
var lis = v.all('li');
241
Y.EditorSelection.filterBlocks();
243
var endTime = (new Date()).getTime();
247
* Method attempts to replace all "orphined" text nodes in the main body by wrapping them with a <p>. Called from filter.
249
* @method filterBlocks
251
Y.EditorSelection.filterBlocks = function() {
252
var startTime = (new Date()).getTime();
253
var childs = Y.config.doc.body.childNodes, i, node, wrapped = false, doit = true,
254
sel, single, br, divs, spans, c, s;
257
for (i = 0; i < childs.length; i++) {
258
node = Y.one(childs[i]);
259
if (!node.test(Y.EditorSelection.BLOCKS)) {
261
if (childs[i].nodeType == 3) {
262
c = childs[i][textContent].match(Y.EditorSelection.REG_CHAR);
263
s = childs[i][textContent].match(Y.EditorSelection.REG_NON);
264
if (c === null && s) {
273
wrapped.push(childs[i]);
276
wrapped = Y.EditorSelection._wrapBlock(wrapped);
279
wrapped = Y.EditorSelection._wrapBlock(wrapped);
282
single = Y.all(Y.EditorSelection.DEFAULT_BLOCK_TAG);
283
if (single.size() === 1) {
284
br = single.item(0).all('br');
285
if (br.size() === 1) {
286
if (!br.item(0).test('.yui-cursor')) {
289
var html = single.item(0).get('innerHTML');
290
if (html === '' || html === ' ') {
291
single.set('innerHTML', Y.EditorSelection.CURSOR);
292
sel = new Y.EditorSelection();
293
sel.focusCursor(true, true);
295
if (br.item(0).test('.yui-cursor') && Y.UA.ie) {
300
single.each(function(p) {
301
var html = p.get('innerHTML');
310
divs = Y.all('div, p');
311
divs.each(function(d) {
312
if (d.hasClass('yui-non')) {
315
var html = d.get('innerHTML');
319
if (d.get('childNodes').size() == 1) {
320
if (d.ancestor('p')) {
321
d.replace(d.get('firstChild'));
327
/* Removed this, as it was causing Pasting to be funky in Safari
328
spans = Y.all('.Apple-style-span, .apple-style-span');
329
spans.each(function(s) {
330
s.setAttribute('style', '');
336
var endTime = (new Date()).getTime();
340
* Regular Expression used to find dead font-family styles
342
* @property REG_FONTFAMILY
344
Y.EditorSelection.REG_FONTFAMILY = /font-family: ;/;
347
* Regular Expression to determine if a string has a character in it
351
Y.EditorSelection.REG_CHAR = /[a-zA-Z-0-9_!@#\$%\^&*\(\)-=_+\[\]\\{}|;':",.\/<>\?]/gi;
354
* Regular Expression to determine if a string has a non-character in it
358
Y.EditorSelection.REG_NON = /[\s|\n|\t]/gi;
361
* Regular Expression to remove all HTML from a string
363
* @property REG_NOHTML
365
Y.EditorSelection.REG_NOHTML = /<\S[^><]*>/g;
369
* Wraps an array of elements in a Block level tag
374
Y.EditorSelection._wrapBlock = function(wrapped) {
376
var newChild = Y.Node.create('<' + Y.EditorSelection.DEFAULT_BLOCK_TAG + '></' + Y.EditorSelection.DEFAULT_BLOCK_TAG + '>'),
377
firstChild = Y.one(wrapped[0]), i;
379
for (i = 1; i < wrapped.length; i++) {
380
newChild.append(wrapped[i]);
382
firstChild.replace(newChild);
383
newChild.prepend(firstChild);
389
* Undoes what filter does enough to return the HTML from the Editor, then re-applies the filter.
392
* @return {String} The filtered HTML
394
Y.EditorSelection.unfilter = function() {
395
var nodes = Y.all('body [class]'),
396
html = '', nons, ids,
397
body = Y.one('body');
400
nodes.each(function(n) {
401
if (n.hasClass(n._yuid)) {
403
n.setStyle(FONT_FAMILY, n.getStyle(FONT_FAMILY));
404
n.removeClass(n._yuid);
405
if (n.getAttribute('class') === '') {
406
n.removeAttribute('class');
411
nons = Y.all('.yui-non');
412
nons.each(function(n) {
413
if (!n.hasClass('yui-skip') && n.get('innerHTML') === '') {
416
n.removeClass('yui-non').removeClass('yui-skip');
420
ids = Y.all('body [id]');
421
ids.each(function(n) {
422
if (n.get('id').indexOf('yui_3_') === 0) {
423
n.removeAttribute('id');
424
n.removeAttribute('_yuid');
429
html = body.get('innerHTML');
432
Y.all('.hr').addClass('yui-skip').addClass('yui-non');
435
nodes.each(function(n) {
437
n.setStyle(FONT_FAMILY, '');
438
if (n.getAttribute('style') === '') {
439
n.removeAttribute('style');
447
* Resolve a node from the selection object and return a Node instance
450
* @param {HTMLElement} n The HTMLElement to resolve. Might be a TextNode, gives parentNode.
451
* @return {Node} The Resolved node
453
Y.EditorSelection.resolve = function(n) {
454
if (n && n.nodeType === 3) {
455
//Adding a try/catch here because in rare occasions IE will
456
//Throw a error accessing the parentNode of a stranded text node.
457
//In the case of Ctrl+Z (Undo)
468
* Returns the innerHTML of a node with all HTML tags removed.
471
* @param {Node} node The Node instance to remove the HTML from
472
* @return {String} The string of text
474
Y.EditorSelection.getText = function(node) {
475
var txt = node.get('innerHTML').replace(Y.EditorSelection.REG_NOHTML, '');
476
//Clean out the cursor subs to see if the Node is empty
477
txt = txt.replace('<span><br></span>', '').replace('<br>', '');
481
//Y.EditorSelection.DEFAULT_BLOCK_TAG = 'div';
482
Y.EditorSelection.DEFAULT_BLOCK_TAG = 'p';
485
* The selector to use when looking for Nodes to cache the value of: [style],font[face]
489
Y.EditorSelection.ALL = '[style],font[face]';
492
* The selector to use when looking for block level items.
496
Y.EditorSelection.BLOCKS = 'p,div,ul,ol,table,style';
498
* The temporary fontname applied to a selection to retrieve their values: yui-tmp
502
Y.EditorSelection.TMP = 'yui-tmp';
504
* The default tag to use when creating elements: span
506
* @property DEFAULT_TAG
508
Y.EditorSelection.DEFAULT_TAG = 'span';
511
* The id of the outer cursor wrapper
513
* @property DEFAULT_TAG
515
Y.EditorSelection.CURID = 'yui-cursor';
518
* The id used to wrap the inner space of the cursor position
520
* @property CUR_WRAPID
522
Y.EditorSelection.CUR_WRAPID = 'yui-cursor-wrapper';
525
* The default HTML used to focus the cursor..
529
Y.EditorSelection.CURSOR = '<span><br class="yui-cursor"></span>';
531
Y.EditorSelection.hasCursor = function() {
532
var cur = Y.all('#' + Y.EditorSelection.CUR_WRAPID);
537
* Called from Editor keydown to remove the "extra" space before the cursor.
539
* @method cleanCursor
541
Y.EditorSelection.cleanCursor = function() {
542
var cur, sel = 'br.yui-cursor';
545
cur.each(function(b) {
546
var c = b.get('parentNode.parentNode.childNodes'), html;
550
html = Y.EditorSelection.getText(c.item(0));
558
var cur = Y.all('#' + Y.EditorSelection.CUR_WRAPID);
560
cur.each(function(c) {
561
var html = c.get('innerHTML');
562
if (html == ' ' || html == '<br>') {
563
if (c.previous() || c.next()) {
572
Y.EditorSelection.prototype = {
580
* Flag to show if the range is collapsed or not
581
* @property isCollapsed
586
* A Node instance of the parentNode of the anchorNode of the range
587
* @property anchorNode
592
* The offset from the range object
593
* @property anchorOffset
598
* A Node instance of the actual textNode of the range.
599
* @property anchorTextNode
602
anchorTextNode: null,
604
* A Node instance of the parentNode of the focusNode of the range
605
* @property focusNode
610
* The offset from the range object
611
* @property focusOffset
616
* A Node instance of the actual textNode of the range.
617
* @property focusTextNode
622
* The actual Selection/Range object
623
* @property _selection
628
* Wrap an element, with another element
631
* @param {HTMLElement} n The node to wrap
632
* @param {String} tag The tag to use when creating the new element.
633
* @return {HTMLElement} The wrapped node
635
_wrap: function(n, tag) {
636
var tmp = Y.Node.create('<' + tag + '></' + tag + '>');
637
tmp.set(INNER_HTML, n.get(INNER_HTML));
638
n.set(INNER_HTML, '');
640
return Y.Node.getDOMNode(tmp);
643
* Swap an element, with another element
646
* @param {HTMLElement} n The node to swap
647
* @param {String} tag The tag to use when creating the new element.
648
* @return {HTMLElement} The new node
650
_swap: function(n, tag) {
651
var tmp = Y.Node.create('<' + tag + '></' + tag + '>');
652
tmp.set(INNER_HTML, n.get(INNER_HTML));
654
return Y.Node.getDOMNode(tmp);
657
* Get all the nodes in the current selection. This method will actually perform a filter first.
658
* Then it calls doc.execCommand('fontname', null, 'yui-tmp') to touch all nodes in the selection.
659
* The it compiles a list of all nodes affected by the execCommand and builds a NodeList to return.
660
* @method getSelected
661
* @return {NodeList} A NodeList of all items in the selection.
663
getSelected: function() {
664
Y.EditorSelection.filter();
665
Y.config.doc.execCommand('fontname', null, Y.EditorSelection.TMP);
666
var nodes = Y.all(Y.EditorSelection.ALL),
669
nodes.each(function(n, k) {
670
if (n.getStyle(FONT_FAMILY) == Y.EditorSelection.TMP) {
671
n.setStyle(FONT_FAMILY, '');
672
Y.EditorSelection.removeFontFamily(n);
673
if (!n.test('body')) {
674
items.push(Y.Node.getDOMNode(nodes.item(k)));
681
* Insert HTML at the current cursor position and return a Node instance of the newly inserted element.
682
* @method insertContent
683
* @param {String} html The HTML to insert.
684
* @return {Node} The inserted Node.
686
insertContent: function(html) {
687
return this.insertAtCursor(html, this.anchorTextNode, this.anchorOffset, true);
690
* 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.
691
* @method insertAtCursor
692
* @param {String} html The HTML to insert.
693
* @param {Node} node The text node to break when inserting.
694
* @param {Number} offset The left offset of the text node to break and insert the new content.
695
* @param {Boolean} collapse Should the range be collapsed after insertion. default: false
696
* @return {Node} The inserted Node.
698
insertAtCursor: function(html, node, offset, collapse) {
699
var cur = Y.Node.create('<' + Y.EditorSelection.DEFAULT_TAG + ' class="yui-non"></' + Y.EditorSelection.DEFAULT_TAG + '>'),
700
inHTML, txt, txt2, newNode, range = this.createRange(), b;
702
if (node && node.test('body')) {
703
b = Y.Node.create('<span></span>');
709
if (range.pasteHTML) {
710
if (offset === 0 && node && !node.previous() && node.get('nodeType') === 3) {
712
* For some strange reason, range.pasteHTML fails if the node is a textNode and
713
* the offset is 0. (The cursor is at the beginning of the line)
714
* It will always insert the new content at position 1 instead of
715
* position 0. Here we test for that case and do it the hard way.
717
node.insert(html, 'before');
718
if (range.moveToElementText) {
719
range.moveToElementText(Y.Node.getDOMNode(node.previous()));
721
//Move the cursor after the new node
722
range.collapse(false);
724
return node.previous();
726
newNode = Y.Node.create(html);
728
range.pasteHTML('<span id="rte-insert"></span>');
730
inHTML = Y.one('#rte-insert');
732
inHTML.set('id', '');
733
inHTML.replace(newNode);
734
if (range.moveToElementText) {
735
range.moveToElementText(Y.Node.getDOMNode(newNode));
737
range.collapse(false);
741
Y.on('available', function() {
742
inHTML.set('id', '');
743
inHTML.replace(newNode);
744
if (range.moveToElementText) {
745
range.moveToElementText(Y.Node.getDOMNode(newNode));
747
range.collapse(false);
753
//TODO using Y.Node.create here throws warnings & strips first white space character
754
//txt = Y.one(Y.Node.create(inHTML.substr(0, offset)));
755
//txt2 = Y.one(Y.Node.create(inHTML.substr(offset)));
757
inHTML = node.get(textContent);
759
txt = Y.one(Y.config.doc.createTextNode(inHTML.substr(0, offset)));
760
txt2 = Y.one(Y.config.doc.createTextNode(inHTML.substr(offset)));
762
node.replace(txt, node);
763
newNode = Y.Node.create(html);
764
if (newNode.get('nodeType') === 11) {
765
b = Y.Node.create('<span></span>');
769
txt.insert(newNode, 'after');
770
//if (txt2 && txt2.get('length')) {
772
newNode.insert(cur, 'after');
773
cur.insert(txt2, 'after');
774
this.selectNode(cur, collapse);
777
if (node.get('nodeType') === 3) {
778
node = node.get('parentNode');
780
newNode = Y.Node.create(html);
781
html = node.get('innerHTML').replace(/\n/gi, '');
782
if (html === '' || html === '<br>') {
783
node.append(newNode);
785
if (newNode.get('parentNode')) {
786
node.insert(newNode, 'before');
788
Y.one('body').prepend(newNode);
791
if (node.get('firstChild').test('br')) {
792
node.get('firstChild').remove();
799
* Get all elements inside a selection and wrap them with a new element and return a NodeList of all elements touched.
800
* @method wrapContent
801
* @param {String} tag The tag to wrap all selected items with.
802
* @return {NodeList} A NodeList of all items in the selection.
804
wrapContent: function(tag) {
805
tag = (tag) ? tag : Y.EditorSelection.DEFAULT_TAG;
807
if (!this.isCollapsed) {
808
var items = this.getSelected(),
809
changed = [], range, last, first, range2;
811
items.each(function(n, k) {
812
var t = n.get('tagName').toLowerCase();
814
changed.push(this._swap(items.item(k), tag));
816
changed.push(this._wrap(items.item(k), tag));
820
range = this.createRange();
822
last = changed[changed.length - 1];
823
if (this._selection.removeAllRanges) {
824
range.setStart(changed[0], 0);
825
range.setEnd(last, last.childNodes.length);
826
this._selection.removeAllRanges();
827
this._selection.addRange(range);
829
if (range.moveToElementText) {
830
range.moveToElementText(Y.Node.getDOMNode(first));
831
range2 = this.createRange();
832
range2.moveToElementText(Y.Node.getDOMNode(last));
833
range.setEndPoint('EndToEnd', range2);
838
changed = Y.all(changed);
847
* Find and replace a string inside a text node and replace it with HTML focusing the node after
848
* to allow you to continue to type.
850
* @param {String} se The string to search for.
851
* @param {String} re The string of HTML to replace it with.
852
* @return {Node} The node inserted.
854
replace: function(se,re) {
855
var range = this.createRange(), node, txt, index, newNode;
857
if (range.getBookmark) {
858
index = range.getBookmark();
859
txt = this.anchorNode.get('innerHTML').replace(se, re);
860
this.anchorNode.set('innerHTML', txt);
861
range.moveToBookmark(index);
862
newNode = Y.one(range.parentElement());
864
node = this.anchorTextNode;
865
txt = node.get(textContent);
866
index = txt.indexOf(se);
868
txt = txt.replace(se, '');
869
node.set(textContent, txt);
870
newNode = this.insertAtCursor(re, node, index, true);
878
* @return {EditorSelection}
881
if (this._selection && this._selection.removeAllRanges) {
882
this._selection.removeAllRanges();
887
* Wrapper for the different range creation methods.
888
* @method createRange
889
* @return {RangeObject}
891
createRange: function() {
892
if (Y.config.doc.selection) {
893
return Y.config.doc.selection.createRange();
895
return Y.config.doc.createRange();
899
* Select a Node (hilighting it).
901
* @param {Node} node The node to select
902
* @param {Boolean} collapse Should the range be collapsed after insertion. default: false
904
* @return {EditorSelection}
906
selectNode: function(node, collapse, end) {
911
node = Y.Node.getDOMNode(node);
912
var range = this.createRange();
913
if (range.selectNode) {
914
range.selectNode(node);
915
this._selection.removeAllRanges();
916
this._selection.addRange(range);
919
this._selection.collapse(node, end);
921
this._selection.collapse(node, 0);
925
if (node.nodeType === 3) {
926
node = node.parentNode;
929
range.moveToElementText(node);
932
range.collapse(((end) ? false : true));
939
* Put a placeholder in the DOM at the current cursor position.
943
setCursor: function() {
944
this.removeCursor(false);
945
return this.insertContent(Y.EditorSelection.CURSOR);
948
* Get the placeholder in the DOM at the current cursor position.
952
getCursor: function() {
953
return Y.all('#' + Y.EditorSelection.CURID);
956
* Remove the cursor placeholder from the DOM.
957
* @method removeCursor
958
* @param {Boolean} keep Setting this to true will keep the node, but remove the unique parts that make it the cursor.
961
removeCursor: function(keep) {
962
var cur = this.getCursor();
965
cur.removeAttribute('id');
966
cur.set('innerHTML', '<br class="yui-cursor">');
974
* Gets a stored cursor and focuses it for editing, must be called sometime after setCursor
975
* @method focusCursor
978
focusCursor: function(collapse, end) {
979
if (collapse !== false) {
985
var cur = this.removeCursor(true);
987
cur.each(function(c) {
988
this.selectNode(c, collapse, end);
993
* Generic toString for logging.
997
toString: function() {
998
return 'EditorSelection Object';
1002
//TODO Remove this alias in 3.6.0
1003
Y.Selection = Y.EditorSelection;
1007
}, '3.5.0' ,{skinnable:false, requires:['node']});