1 /* 2 Copyright (c) 2003-2009, CKSource - Frederico Knabben. All rights reserved. 3 For licensing, see LICENSE.html or http://ckeditor.com/license 4 */ 5 6 (function() 7 { 8 // Element tag names which prevent characters counting. 9 var characterBoundaryElementsEnum = 10 { 11 address :1, blockquote :1, dl :1, h1 :1, h2 :1, h3 :1, 12 h4 :1, h5 :1, h6 :1, p :1, pre :1, li :1, dt :1, de :1, div :1, td:1, th:1 13 }; 14 15 var guardDomWalkerNonEmptyTextNode = function( evt ) 16 { 17 if ( evt.data.to && evt.data.to.type == CKEDITOR.NODE_TEXT 18 && evt.data.to.$.length > 0 ) 19 this.stop(); 20 CKEDITOR.dom.domWalker.blockBoundary( { br : 1 } ).call( this, evt ); 21 }; 22 23 24 /** 25 * Get the cursor object which represent both current character and it's dom 26 * position thing. 27 */ 28 var cursorStep = function() 29 { 30 var obj = { 31 textNode : this.textNode, 32 offset : this.offset, 33 character : this.textNode ? this.textNode.getText().charAt( this.offset ) : null, 34 hitMatchBoundary : this._.matchBoundary 35 }; 36 return obj; 37 }; 38 39 var pages = [ 'find', 'replace' ], 40 fieldsMapping = [ 41 [ 'txtFindFind', 'txtFindReplace' ], 42 [ 'txtFindCaseChk', 'txtReplaceCaseChk' ], 43 [ 'txtFindWordChk', 'txtReplaceWordChk' ], 44 [ 'txtFindCyclic', 'txtReplaceCyclic' ] ]; 45 46 /** 47 * Synchronize corresponding filed values between 'replace' and 'find' pages. 48 * @param {String} currentPageId The page id which receive values. 49 */ 50 function syncFieldsBetweenTabs( currentPageId ) 51 { 52 var sourceIndex, targetIndex, 53 sourceField, targetField; 54 55 sourceIndex = currentPageId === 'find' ? 1 : 0; 56 targetIndex = 1 - sourceIndex; 57 var i, l = fieldsMapping.length; 58 for ( i = 0 ; i < l ; i++ ) 59 { 60 var sourceField = this.getContentElement( pages[ sourceIndex ], 61 fieldsMapping[ i ][ sourceIndex ] ); 62 var targetField = this.getContentElement( pages[ targetIndex ], 63 fieldsMapping[ i ][ targetIndex ] ); 64 65 targetField.setValue( sourceField.getValue() ); 66 } 67 } 68 69 var findDialog = function( editor, startupPage ) 70 { 71 // Style object for highlights. 72 var highlightStyle = new CKEDITOR.style( editor.config.find_highlight ); 73 74 /** 75 * Iterator which walk through document char by char. 76 * @param {Object} start 77 * @param {Number} offset 78 */ 79 var characterWalker = function( start, offset ) 80 { 81 var isCursor = typeof( start.textNode ) !== 'undefined'; 82 this.textNode = isCursor ? start.textNode : start; 83 this.offset = isCursor ? start.offset : offset; 84 this._ = { 85 walker : new CKEDITOR.dom.domWalker( this.textNode ), 86 matchBoundary : false 87 }; 88 }; 89 90 characterWalker.prototype = { 91 next : function() 92 { 93 // Already at the end of document, no more character available. 94 if( this.textNode == null ) 95 return cursorStep.call( this ); 96 97 this._.matchBoundary = false; 98 99 // If there are more characters in the text node, get it and 100 // raise an event. 101 if( this.textNode.type == CKEDITOR.NODE_TEXT 102 && this.offset < this.textNode.getLength() - 1 ) 103 { 104 this.offset++; 105 return cursorStep.call( this ); 106 } 107 108 // If we are at the end of the text node, use dom walker to get 109 // the next text node. 110 var data = null; 111 while ( !data || ( data.node && data.node.type != 112 CKEDITOR.NODE_TEXT ) ) 113 { 114 data = this._.walker.forward( 115 guardDomWalkerNonEmptyTextNode ); 116 117 // Block boundary? BR? Document boundary? 118 if ( !data.node 119 || ( data.node.type !== CKEDITOR.NODE_TEXT 120 && data.node.getName() in 121 characterBoundaryElementsEnum ) ) 122 this._.matchBoundary = true; 123 } 124 this.textNode = data.node; 125 this.offset = 0; 126 return cursorStep.call( this ); 127 }, 128 129 back : function() 130 { 131 this._.matchBoundary = false; 132 133 // More characters -> decrement offset and return. 134 if ( this.textNode.type == CKEDITOR.NODE_TEXT && this.offset > 0 ) 135 { 136 this.offset--; 137 return cursorStep.call( this ); 138 } 139 140 // Start of text node -> use dom walker to get the previous text node. 141 var data = null; 142 while ( !data 143 || ( data.node && data.node.type != CKEDITOR.NODE_TEXT ) ) 144 { 145 data = this._.walker.reverse( guardDomWalkerNonEmptyTextNode ); 146 147 // Block boundary? BR? Document boundary? 148 if ( !data.node || ( data.node.type !== CKEDITOR.NODE_TEXT && 149 data.node.getName() in characterBoundaryElementsEnum ) ) 150 this._.matchBoundary = true; 151 } 152 this.textNode = data.node; 153 this.offset = data.node.length - 1; 154 return cursorStep.call( this ); 155 } 156 }; 157 158 /** 159 * A range of cursors which represent a trunk of characters which try to 160 * match, it has the same length as the pattern string. 161 */ 162 var characterRange = function( characterWalker, rangeLength ) 163 { 164 this._ = { 165 walker : characterWalker, 166 cursors : [], 167 rangeLength : rangeLength, 168 highlightRange : null, 169 isMatched : false 170 }; 171 }; 172 173 characterRange.prototype = { 174 /** 175 * Translate this range to {@link CKEDITOR.dom.range} 176 */ 177 toDomRange : function() 178 { 179 var cursors = this._.cursors; 180 if ( cursors.length < 1 ) 181 return null; 182 183 var first = cursors[0], 184 last = cursors[ cursors.length - 1 ], 185 range = new CKEDITOR.dom.range( editor.document ); 186 187 range.setStart( first.textNode, first.offset ); 188 range.setEnd( last.textNode, last.offset + 1 ); 189 return range; 190 }, 191 192 updateFromDomRange : function( domRange ) 193 { 194 var startNode = domRange.startContainer, 195 startIndex = domRange.startOffset, 196 endNode = domRange.endContainer, 197 endIndex = domRange.endOffset, 198 boundaryNodes = domRange.getBoundaryNodes(); 199 200 if ( startNode.type != CKEDITOR.NODE_TEXT ) 201 { 202 startNode = boundaryNodes.startNode; 203 while ( startNode.type != CKEDITOR.NODE_TEXT ) 204 startNode = startNode.getFirst(); 205 startIndex = 0; 206 } 207 208 if ( endNode.type != CKEDITOR.NODE_TEXT ) 209 { 210 endNode = boundaryNodes.endNode; 211 while ( endNode.type != CKEDITOR.NODE_TEXT ) 212 endNode = endNode.getLast(); 213 endIndex = endNode.getLength(); 214 } 215 216 // If the endNode is an empty text node, our walker would just walk through 217 // it without stopping. So need to backtrack to the nearest non-emtpy text 218 // node. 219 if ( endNode.getLength() < 1 ) 220 { 221 while ( ( endNode = endNode.getPreviousSourceNode() ) && !( endNode.type == CKEDITOR.NODE_TEXT && endNode.getLength() > 0 ) ); 222 endIndex = endNode.getLength(); 223 } 224 225 var cursor = new characterWalker( startNode, startIndex ); 226 this._.cursors = [ cursor ]; 227 if ( !( cursor.textNode.equals( endNode ) && cursor.offset == endIndex - 1 ) ) 228 { 229 do 230 { 231 cursor = new characterWalker( cursor ); 232 cursor.next(); 233 this._.cursors.push( cursor ); 234 } 235 while ( !( cursor.textNode.equals( endNode ) && cursor.offset == endIndex - 1 ) ); 236 } 237 238 this._.rangeLength = this._.cursors.length; 239 }, 240 241 setMatched : function() 242 { 243 this._.isMatched = true; 244 this.highlight(); 245 }, 246 247 clearMatched : function() 248 { 249 this._.isMatched = false; 250 this.removeHighlight(); 251 }, 252 253 isMatched : function() 254 { 255 return this._.isMatched; 256 }, 257 258 /** 259 * Hightlight the current matched chunk of text. 260 */ 261 highlight : function() 262 { 263 // Do not apply if nothing is found. 264 if ( this._.cursors.length < 1 ) 265 return; 266 267 // Remove the previous highlight if there's one. 268 if ( this._.highlightRange ) 269 this.removeHighlight(); 270 271 // Apply the highlight. 272 var range = this.toDomRange(); 273 highlightStyle.applyToRange( range ); 274 this._.highlightRange = range; 275 276 // Scroll the editor to the highlighted area. 277 var element = range.startContainer; 278 if ( element.type != CKEDITOR.NODE_ELEMENT ) 279 element = element.getParent(); 280 element.scrollIntoView(); 281 282 // Update the character cursors. 283 this.updateFromDomRange( range ); 284 }, 285 286 /** 287 * Remove highlighted find result. 288 */ 289 removeHighlight : function() 290 { 291 if ( this._.highlightRange == null ) 292 return; 293 294 highlightStyle.removeFromRange( this._.highlightRange ); 295 this.updateFromDomRange( this._.highlightRange ); 296 this._.highlightRange = null; 297 }, 298 299 moveBack : function() 300 { 301 var retval = this._.walker.back(), 302 cursors = this._.cursors; 303 304 if ( retval.hitMatchBoundary ) 305 this._.cursors = cursors = []; 306 307 cursors.unshift( retval ); 308 if ( cursors.length > this._.rangeLength ) 309 cursors.pop(); 310 311 return retval; 312 }, 313 314 moveNext : function() 315 { 316 var retval = this._.walker.next(), 317 cursors = this._.cursors; 318 319 // Clear the cursors queue if we've crossed a match boundary. 320 if ( retval.hitMatchBoundary ) 321 this._.cursors = cursors = []; 322 323 cursors.push( retval ); 324 if ( cursors.length > this._.rangeLength ) 325 cursors.shift(); 326 327 return retval; 328 }, 329 330 getEndCharacter : function() 331 { 332 var cursors = this._.cursors; 333 if ( cursors.length < 1 ) 334 return null; 335 336 return cursors[ cursors.length - 1 ].character; 337 }, 338 339 getNextRange : function( maxLength ) 340 { 341 var cursors = this._.cursors; 342 if ( cursors.length < 1 ) 343 return null; 344 345 var next = new characterWalker( cursors[ cursors.length - 1 ] ); 346 return new characterRange( next, maxLength ); 347 }, 348 349 getCursors : function() 350 { 351 return this._.cursors; 352 } 353 }; 354 355 var KMP_NOMATCH = 0, 356 KMP_ADVANCED = 1, 357 KMP_MATCHED = 2; 358 /** 359 * Examination the occurrence of a word which implement KMP algorithm. 360 */ 361 var kmpMatcher = function( pattern, ignoreCase ) 362 { 363 var overlap = [ -1 ]; 364 if ( ignoreCase ) 365 pattern = pattern.toLowerCase(); 366 for ( var i = 0 ; i < pattern.length ; i++ ) 367 { 368 overlap.push( overlap[i] + 1 ); 369 while ( overlap[ i + 1 ] > 0 370 && pattern.charAt( i ) != pattern 371 .charAt( overlap[ i + 1 ] - 1 ) ) 372 overlap[ i + 1 ] = overlap[ overlap[ i + 1 ] - 1 ] + 1; 373 } 374 375 this._ = { 376 overlap : overlap, 377 state : 0, 378 ignoreCase : !!ignoreCase, 379 pattern : pattern 380 }; 381 }; 382 383 kmpMatcher.prototype = 384 { 385 feedCharacter : function( c ) 386 { 387 if ( this._.ignoreCase ) 388 c = c.toLowerCase(); 389 390 while ( true ) 391 { 392 if ( c == this._.pattern.charAt( this._.state ) ) 393 { 394 this._.state++; 395 if ( this._.state == this._.pattern.length ) 396 { 397 this._.state = 0; 398 return KMP_MATCHED; 399 } 400 return KMP_ADVANCED; 401 } 402 else if ( this._.state == 0 ) 403 return KMP_NOMATCH; 404 else 405 this._.state = this._.overlap[ this._.state ]; 406 } 407 408 return null; 409 }, 410 411 reset : function() 412 { 413 this._.state = 0; 414 } 415 }; 416 417 var wordSeparatorRegex = 418 /[.,"'?!;: \u0085\u00a0\u1680\u280e\u2028\u2029\u202f\u205f\u3000]/; 419 420 var isWordSeparator = function( c ) 421 { 422 if ( !c ) 423 return true; 424 var code = c.charCodeAt( 0 ); 425 return ( code >= 9 && code <= 0xd ) 426 || ( code >= 0x2000 && code <= 0x200a ) 427 || wordSeparatorRegex.test( c ); 428 }; 429 430 var finder = { 431 startCursor : null, 432 range : null, 433 find : function( pattern, matchCase, matchWord, matchCyclic ) 434 { 435 if( !this.range ) 436 this.range = new characterRange( new characterWalker( this.startCursor ), pattern.length ); 437 else 438 { 439 this.range.removeHighlight(); 440 this.range = this.range.getNextRange( pattern.length ); 441 } 442 443 var matcher = new kmpMatcher( pattern, !matchCase ), 444 matchState = KMP_NOMATCH, 445 character = '%'; 446 447 while ( character != null ) 448 { 449 this.range.moveNext(); 450 while ( ( character = this.range.getEndCharacter() ) ) 451 { 452 matchState = matcher.feedCharacter( character ); 453 if ( matchState == KMP_MATCHED ) 454 break; 455 if ( this.range.moveNext().hitMatchBoundary ) 456 matcher.reset(); 457 } 458 459 if ( matchState == KMP_MATCHED ) 460 { 461 if ( matchWord ) 462 { 463 var cursors = this.range.getCursors(), 464 tail = cursors[ cursors.length - 1 ], 465 head = cursors[ 0 ], 466 headWalker = new characterWalker( head ), 467 tailWalker = new characterWalker( tail ); 468 469 if ( ! ( isWordSeparator( 470 headWalker.back().character ) 471 && isWordSeparator( 472 tailWalker.next().character ) ) ) 473 continue; 474 } 475 476 this.range.setMatched(); 477 return true; 478 } 479 } 480 481 this.range.clearMatched(); 482 483 // clear current session and restart from beginning 484 if ( matchCyclic ) 485 { 486 this.startCursor = getDefaultStartCursor(); 487 this.range = null; 488 } 489 490 return false; 491 }, 492 493 /** 494 * Record how much replacement occurred toward one replacing. 495 */ 496 replaceCounter : 0, 497 498 replace : function( dialog, pattern, newString, matchCase, matchWord, 499 matchCyclic, matchReplaceAll ) 500 { 501 var replaceResult = false; 502 if ( this.range && this.range.isMatched() ) 503 { 504 var domRange = this.range.toDomRange(); 505 var text = editor.document.createText( newString ); 506 domRange.deleteContents(); 507 domRange.insertNode( text ); 508 this.range.updateFromDomRange( domRange ); 509 510 this.replaceCounter++; 511 replaceResult = true; 512 } 513 514 var findResult = this.find( pattern, matchCase, matchWord, matchCyclic ); 515 if ( findResult && matchReplaceAll ) 516 this.replace.apply( this, Array.prototype.slice.call( arguments ) ); 517 return matchReplaceAll ? 518 this.replaceCounter : replaceResult || findResult; 519 } 520 }; 521 522 /** 523 * Get the default cursor which is the start of this document. 524 */ 525 function getDefaultStartCursor() 526 { 527 return { textNode : editor.document.getBody(), offset: 0 }; 528 } 529 530 /** 531 * Get cursor that indicate search begin with, receive from user 532 * selection prior. 533 */ 534 function getStartCursor() 535 { 536 if ( CKEDITOR.env.ie ) 537 this.restoreSelection(); 538 539 var sel = editor.getSelection(); 540 if ( sel ) 541 { 542 var lastRange = sel.getRanges()[ sel.getRanges().length - 1 ]; 543 return { 544 textNode : lastRange.getBoundaryNodes().endNode, 545 offset : lastRange.endContainer.type === 546 CKEDITOR.NODE_ELEMENT ? 547 0 : lastRange.endOffset 548 }; 549 } 550 else 551 return getDefaultStartCursor(); 552 } 553 554 return { 555 title : editor.lang.findAndReplace.title, 556 resizable : CKEDITOR.DIALOG_RESIZE_NONE, 557 minWidth : 400, 558 minHeight : 255, 559 buttons : [ CKEDITOR.dialog.cancelButton ], //Cancel button only. 560 contents : [ 561 { 562 id : 'find', 563 label : editor.lang.findAndReplace.find, 564 title : editor.lang.findAndReplace.find, 565 accessKey : '', 566 elements : [ 567 { 568 type : 'hbox', 569 widths : [ '230px', '90px' ], 570 children : 571 [ 572 { 573 type : 'text', 574 id : 'txtFindFind', 575 label : editor.lang.findAndReplace.findWhat, 576 isChanged : false, 577 labelLayout : 'horizontal', 578 accessKey : 'F' 579 }, 580 { 581 type : 'button', 582 align : 'left', 583 style : 'width:100%', 584 label : editor.lang.findAndReplace.find, 585 onClick : function() 586 { 587 var dialog = this.getDialog(); 588 if ( !finder.find( dialog.getValueOf( 'find', 'txtFindFind' ), 589 dialog.getValueOf( 'find', 'txtFindCaseChk' ), 590 dialog.getValueOf( 'find', 'txtFindWordChk' ), 591 dialog.getValueOf( 'find', 'txtFindCyclic' ) ) ) 592 alert( editor.lang.findAndReplace 593 .notFoundMsg ); 594 } 595 } 596 ] 597 }, 598 { 599 type : 'vbox', 600 padding : 0, 601 children : 602 [ 603 { 604 type : 'checkbox', 605 id : 'txtFindCaseChk', 606 isChanged : false, 607 style : 'margin-top:28px', 608 label : editor.lang.findAndReplace.matchCase 609 }, 610 { 611 type : 'checkbox', 612 id : 'txtFindWordChk', 613 isChanged : false, 614 label : editor.lang.findAndReplace.matchWord 615 }, 616 { 617 type : 'checkbox', 618 id : 'txtFindCyclic', 619 isChanged : false, 620 'default' : true, 621 label : editor.lang.findAndReplace.matchCyclic 622 } 623 ] 624 } 625 ] 626 }, 627 { 628 id : 'replace', 629 label : editor.lang.findAndReplace.replace, 630 accessKey : 'M', 631 elements : [ 632 { 633 type : 'hbox', 634 widths : [ '230px', '90px' ], 635 children : 636 [ 637 { 638 type : 'text', 639 id : 'txtFindReplace', 640 label : editor.lang.findAndReplace.findWhat, 641 isChanged : false, 642 labelLayout : 'horizontal', 643 accessKey : 'F' 644 }, 645 { 646 type : 'button', 647 align : 'left', 648 style : 'width:100%', 649 label : editor.lang.findAndReplace.replace, 650 onClick : function() 651 { 652 var dialog = this.getDialog(); 653 if ( !finder.replace( dialog, 654 dialog.getValueOf( 'replace', 'txtFindReplace' ), 655 dialog.getValueOf( 'replace', 'txtReplace' ), 656 dialog.getValueOf( 'replace', 'txtReplaceCaseChk' ), 657 dialog.getValueOf( 'replace', 'txtReplaceWordChk' ), 658 dialog.getValueOf( 'replace', 'txtReplaceCyclic' ) ) ) 659 alert( editor.lang.findAndReplace 660 .notFoundMsg ); 661 } 662 } 663 ] 664 }, 665 { 666 type : 'hbox', 667 widths : [ '230px', '90px' ], 668 children : 669 [ 670 { 671 type : 'text', 672 id : 'txtReplace', 673 label : editor.lang.findAndReplace.replaceWith, 674 isChanged : false, 675 labelLayout : 'horizontal', 676 accessKey : 'R' 677 }, 678 { 679 type : 'button', 680 align : 'left', 681 style : 'width:100%', 682 label : editor.lang.findAndReplace.replaceAll, 683 isChanged : false, 684 onClick : function() 685 { 686 var dialog = this.getDialog(); 687 var replaceNums; 688 689 finder.replaceCounter = 0; 690 if ( ( replaceNums = finder.replace( dialog, 691 dialog.getValueOf( 'replace', 'txtFindReplace' ), 692 dialog.getValueOf( 'replace', 'txtReplace' ), 693 dialog.getValueOf( 'replace', 'txtReplaceCaseChk' ), 694 dialog.getValueOf( 'replace', 'txtReplaceWordChk' ), 695 dialog.getValueOf( 'replace', 'txtReplaceCyclic' ), true ) ) ) 696 alert( editor.lang.findAndReplace.replaceSuccessMsg.replace( /%1/, replaceNums ) ); 697 else 698 alert( editor.lang.findAndReplace.notFoundMsg ); 699 } 700 } 701 ] 702 }, 703 { 704 type : 'vbox', 705 padding : 0, 706 children : 707 [ 708 { 709 type : 'checkbox', 710 id : 'txtReplaceCaseChk', 711 isChanged : false, 712 label : editor.lang.findAndReplace 713 .matchCase 714 }, 715 { 716 type : 'checkbox', 717 id : 'txtReplaceWordChk', 718 isChanged : false, 719 label : editor.lang.findAndReplace 720 .matchWord 721 }, 722 { 723 type : 'checkbox', 724 id : 'txtReplaceCyclic', 725 isChanged : false, 726 'default' : true, 727 label : editor.lang.findAndReplace 728 .matchCyclic 729 } 730 ] 731 } 732 ] 733 } 734 ], 735 onLoad : function() 736 { 737 var dialog = this; 738 739 //keep track of the current pattern field in use. 740 var patternField, wholeWordChkField; 741 742 //Ignore initial page select on dialog show 743 var isUserSelect = false; 744 this.on('hide', function() 745 { 746 isUserSelect = false; 747 } ); 748 this.on('show', function() 749 { 750 isUserSelect = true; 751 } ); 752 753 this.selectPage = CKEDITOR.tools.override( this.selectPage, function( originalFunc ) 754 { 755 return function( pageId ) 756 { 757 originalFunc.call( dialog, pageId ); 758 759 var currPage = dialog._.tabs[ pageId ]; 760 var patternFieldInput, patternFieldId, wholeWordChkFieldId; 761 patternFieldId = pageId === 'find' ? 'txtFindFind' : 'txtFindReplace'; 762 wholeWordChkFieldId = pageId === 'find' ? 'txtFindWordChk' : 'txtReplaceWordChk'; 763 764 patternField = dialog.getContentElement( pageId, 765 patternFieldId ); 766 wholeWordChkField = dialog.getContentElement( pageId, 767 wholeWordChkFieldId ); 768 769 // prepare for check pattern text filed 'keyup' event 770 if ( !currPage.initialized ) 771 { 772 patternFieldInput = CKEDITOR.document 773 .getById( patternField._.inputId ); 774 currPage.initialized = true; 775 } 776 777 if( isUserSelect ) 778 // synchronize fields on tab switch. 779 syncFieldsBetweenTabs.call( this, pageId ); 780 }; 781 } ); 782 783 }, 784 onShow : function() 785 { 786 // Establish initial searching start position. 787 finder.startCursor = getStartCursor.call( this ); 788 789 if ( startupPage == 'replace' ) 790 this.getContentElement( 'replace', 'txtFindReplace' ).focus(); 791 else 792 this.getContentElement( 'find', 'txtFindFind' ).focus(); 793 }, 794 onHide : function() 795 { 796 if ( finder.range && finder.range.isMatched() ) 797 { 798 finder.range.removeHighlight(); 799 editor.getSelection().selectRanges( 800 [ finder.range.toDomRange() ] ); 801 } 802 803 // Clear current session before dialog close 804 delete finder.range; 805 } 806 }; 807 }; 808 809 CKEDITOR.dialog.add( 'find', function( editor ){ 810 return findDialog( editor, 'find' ) 811 } 812 ); 813 814 CKEDITOR.dialog.add( 'replace', function( editor ){ 815 return findDialog( editor, 'replace' ) 816 } 817 ); 818 })(); 819