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