/** * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or http://ckeditor.com/license */ ( function() { var isReplace; function findEvaluator( node ) { return node.type == CKEDITOR.NODE_TEXT && node.getLength() > 0 && ( !isReplace || !node.isReadOnly() ); } // Elements which break characters been considered as sequence. function nonCharactersBoundary( node ) { return !( node.type == CKEDITOR.NODE_ELEMENT && node.isBlockBoundary( CKEDITOR.tools.extend( {}, CKEDITOR.dtd.$empty, CKEDITOR.dtd.$nonEditable ) ) ); } // Get the cursor object which represent both current character and it's dom // position thing. var cursorStep = function() { return { textNode: this.textNode, offset: this.offset, character: this.textNode ? this.textNode.getText().charAt( this.offset ) : null, hitMatchBoundary: this._.matchBoundary }; }; var pages = [ 'find', 'replace' ], fieldsMapping = [ [ 'txtFindFind', 'txtFindReplace' ], [ 'txtFindCaseChk', 'txtReplaceCaseChk' ], [ 'txtFindWordChk', 'txtReplaceWordChk' ], [ 'txtFindCyclic', 'txtReplaceCyclic' ] ]; // Synchronize corresponding filed values between 'replace' and 'find' pages. // @param {String} currentPageId The page id which receive values. function syncFieldsBetweenTabs( currentPageId ) { var sourceIndex, targetIndex, sourceField, targetField; sourceIndex = currentPageId === 'find' ? 1 : 0; targetIndex = 1 - sourceIndex; var i, l = fieldsMapping.length; for ( i = 0; i < l; i++ ) { sourceField = this.getContentElement( pages[ sourceIndex ], fieldsMapping[ i ][ sourceIndex ] ); targetField = this.getContentElement( pages[ targetIndex ], fieldsMapping[ i ][ targetIndex ] ); targetField.setValue( sourceField.getValue() ); } } function findDialog( editor, startupPage ) { // Style object for highlights: (#5018) // 1. Defined as full match style to avoid compromising ordinary text color styles. // 2. Must be apply onto inner-most text to avoid conflicting with ordinary text color styles visually. var highlightConfig = { attributes: { 'data-cke-highlight': 1 }, fullMatch: 1, ignoreReadonly: 1, childRule: function() { return 0; } }; var highlightStyle = new CKEDITOR.style( CKEDITOR.tools.extend( highlightConfig, editor.config.find_highlight, true ) ); // Iterator which walk through the specified range char by char. By // default the walking will not stop at the character boundaries, until // the end of the range is encountered. // @param { CKEDITOR.dom.range } range // @param {Boolean} matchWord Whether the walking will stop at character boundary. function characterWalker( range, matchWord ) { var self = this; var walker = new CKEDITOR.dom.walker( range ); walker.guard = matchWord ? nonCharactersBoundary : function( node ) { !nonCharactersBoundary( node ) && ( self._.matchBoundary = true ); }; walker.evaluator = findEvaluator; walker.breakOnFalse = 1; if ( range.startContainer.type == CKEDITOR.NODE_TEXT ) { this.textNode = range.startContainer; this.offset = range.startOffset - 1; } this._ = { matchWord: matchWord, walker: walker, matchBoundary: false }; } characterWalker.prototype = { next: function() { return this.move(); }, back: function() { return this.move( true ); }, move: function( rtl ) { var currentTextNode = this.textNode; // Already at the end of document, no more character available. if ( currentTextNode === null ) return cursorStep.call( this ); this._.matchBoundary = false; // There are more characters in the text node, step forward. if ( currentTextNode && rtl && this.offset > 0 ) { this.offset--; return cursorStep.call( this ); } else if ( currentTextNode && this.offset < currentTextNode.getLength() - 1 ) { this.offset++; return cursorStep.call( this ); } else { currentTextNode = null; // At the end of the text node, walking foward for the next. while ( !currentTextNode ) { currentTextNode = this._.walker[ rtl ? 'previous' : 'next' ].call( this._.walker ); // Stop searching if we're need full word match OR // already reach document end. if ( this._.matchWord && !currentTextNode || this._.walker._.end ) break; } // Found a fresh text node. this.textNode = currentTextNode; if ( currentTextNode ) this.offset = rtl ? currentTextNode.getLength() - 1 : 0; else this.offset = 0; } return cursorStep.call( this ); } }; /** * A range of cursors which represent a trunk of characters which try to * match, it has the same length as the pattern string. * * **Note:** This class isn't accessible from global scope. * * @private * @class CKEDITOR.plugins.find.characterRange * @constructor Creates a characterRange class instance. */ var characterRange = function( characterWalker, rangeLength ) { this._ = { walker: characterWalker, cursors: [], rangeLength: rangeLength, highlightRange: null, isMatched: 0 }; }; characterRange.prototype = { /** * Translate this range to {@link CKEDITOR.dom.range}. */ toDomRange: function() { var range = editor.createRange(); var cursors = this._.cursors; if ( cursors.length < 1 ) { var textNode = this._.walker.textNode; if ( textNode ) range.setStartAfter( textNode ); else return null; } else { var first = cursors[ 0 ], last = cursors[ cursors.length - 1 ]; range.setStart( first.textNode, first.offset ); range.setEnd( last.textNode, last.offset + 1 ); } return range; }, /** * Reflect the latest changes from dom range. */ updateFromDomRange: function( domRange ) { var cursor, walker = new characterWalker( domRange ); this._.cursors = []; do { cursor = walker.next(); if ( cursor.character ) this._.cursors.push( cursor ); } while ( cursor.character ); this._.rangeLength = this._.cursors.length; }, setMatched: function() { this._.isMatched = true; }, clearMatched: function() { this._.isMatched = false; }, isMatched: function() { return this._.isMatched; }, /** * Hightlight the current matched chunk of text. */ highlight: function() { // Do not apply if nothing is found. if ( this._.cursors.length < 1 ) return; // Remove the previous highlight if there's one. if ( this._.highlightRange ) this.removeHighlight(); // Apply the highlight. var range = this.toDomRange(), bookmark = range.createBookmark(); highlightStyle.applyToRange( range, editor ); range.moveToBookmark( bookmark ); this._.highlightRange = range; // Scroll the editor to the highlighted area. var element = range.startContainer; if ( element.type != CKEDITOR.NODE_ELEMENT ) element = element.getParent(); element.scrollIntoView(); // Update the character cursors. this.updateFromDomRange( range ); }, /** * Remove highlighted find result. */ removeHighlight: function() { if ( !this._.highlightRange ) return; var bookmark = this._.highlightRange.createBookmark(); highlightStyle.removeFromRange( this._.highlightRange, editor ); this._.highlightRange.moveToBookmark( bookmark ); this.updateFromDomRange( this._.highlightRange ); this._.highlightRange = null; }, isReadOnly: function() { if ( !this._.highlightRange ) return 0; return this._.highlightRange.startContainer.isReadOnly(); }, moveBack: function() { var retval = this._.walker.back(), cursors = this._.cursors; if ( retval.hitMatchBoundary ) this._.cursors = cursors = []; cursors.unshift( retval ); if ( cursors.length > this._.rangeLength ) cursors.pop(); return retval; }, moveNext: function() { var retval = this._.walker.next(), cursors = this._.cursors; // Clear the cursors queue if we've crossed a match boundary. if ( retval.hitMatchBoundary ) this._.cursors = cursors = []; cursors.push( retval ); if ( cursors.length > this._.rangeLength ) cursors.shift(); return retval; }, getEndCharacter: function() { var cursors = this._.cursors; if ( cursors.length < 1 ) return null; return cursors[ cursors.length - 1 ].character; }, getNextCharacterRange: function( maxLength ) { var lastCursor, nextRangeWalker, cursors = this._.cursors; if ( ( lastCursor = cursors[ cursors.length - 1 ] ) && lastCursor.textNode ) nextRangeWalker = new characterWalker( getRangeAfterCursor( lastCursor ) ); // In case it's an empty range (no cursors), figure out next range from walker (#4951). else nextRangeWalker = this._.walker; return new characterRange( nextRangeWalker, maxLength ); }, getCursors: function() { return this._.cursors; } }; // The remaining document range after the character cursor. function getRangeAfterCursor( cursor, inclusive ) { var range = editor.createRange(); range.setStart( cursor.textNode, ( inclusive ? cursor.offset : cursor.offset + 1 ) ); range.setEndAt( editor.editable(), CKEDITOR.POSITION_BEFORE_END ); return range; } // The document range before the character cursor. function getRangeBeforeCursor( cursor ) { var range = editor.createRange(); range.setStartAt( editor.editable(), CKEDITOR.POSITION_AFTER_START ); range.setEnd( cursor.textNode, cursor.offset ); return range; } var KMP_NOMATCH = 0, KMP_ADVANCED = 1, KMP_MATCHED = 2; // Examination the occurrence of a word which implement KMP algorithm. var kmpMatcher = function( pattern, ignoreCase ) { var overlap = [ -1 ]; if ( ignoreCase ) pattern = pattern.toLowerCase(); for ( var i = 0; i < pattern.length; i++ ) { overlap.push( overlap[ i ] + 1 ); while ( overlap[ i + 1 ] > 0 && pattern.charAt( i ) != pattern.charAt( overlap[ i + 1 ] - 1 ) ) overlap[ i + 1 ] = overlap[ overlap[ i + 1 ] - 1 ] + 1; } this._ = { overlap: overlap, state: 0, ignoreCase: !!ignoreCase, pattern: pattern }; }; kmpMatcher.prototype = { feedCharacter: function( c ) { if ( this._.ignoreCase ) c = c.toLowerCase(); while ( true ) { if ( c == this._.pattern.charAt( this._.state ) ) { this._.state++; if ( this._.state == this._.pattern.length ) { this._.state = 0; return KMP_MATCHED; } return KMP_ADVANCED; } else if ( !this._.state ) { return KMP_NOMATCH; } else { this._.state = this._.overlap[this._.state]; } } }, reset: function() { this._.state = 0; } }; var wordSeparatorRegex = /[.,"'?!;: \u0085\u00a0\u1680\u280e\u2028\u2029\u202f\u205f\u3000]/; var isWordSeparator = function( c ) { if ( !c ) return true; var code = c.charCodeAt( 0 ); return ( code >= 9 && code <= 0xd ) || ( code >= 0x2000 && code <= 0x200a ) || wordSeparatorRegex.test( c ); }; var finder = { searchRange: null, matchRange: null, find: function( pattern, matchCase, matchWord, matchCyclic, highlightMatched, cyclicRerun ) { if ( !this.matchRange ) this.matchRange = new characterRange( new characterWalker( this.searchRange ), pattern.length ); else { this.matchRange.removeHighlight(); this.matchRange = this.matchRange.getNextCharacterRange( pattern.length ); } var matcher = new kmpMatcher( pattern, !matchCase ), matchState = KMP_NOMATCH, character = '%'; while ( character !== null ) { this.matchRange.moveNext(); while ( ( character = this.matchRange.getEndCharacter() ) ) { matchState = matcher.feedCharacter( character ); if ( matchState == KMP_MATCHED ) break; if ( this.matchRange.moveNext().hitMatchBoundary ) matcher.reset(); } if ( matchState == KMP_MATCHED ) { if ( matchWord ) { var cursors = this.matchRange.getCursors(), tail = cursors[ cursors.length - 1 ], head = cursors[ 0 ]; var rangeBefore = getRangeBeforeCursor( head ), rangeAfter = getRangeAfterCursor( tail ); // The word boundary checks requires to trim the text nodes. (#9036) rangeBefore.trim(); rangeAfter.trim(); var headWalker = new characterWalker( rangeBefore, true ), tailWalker = new characterWalker( rangeAfter, true ); if ( !( isWordSeparator( headWalker.back().character ) && isWordSeparator( tailWalker.next().character ) ) ) continue; } this.matchRange.setMatched(); if ( highlightMatched !== false ) this.matchRange.highlight(); return true; } } this.matchRange.clearMatched(); this.matchRange.removeHighlight(); // Clear current session and restart with the default search // range. // Re-run the finding once for cyclic.(#3517) if ( matchCyclic && !cyclicRerun ) { this.searchRange = getSearchRange( 1 ); this.matchRange = null; return arguments.callee.apply( this, Array.prototype.slice.call( arguments ).concat( [ true ] ) ); } return false; }, // Record how much replacement occurred toward one replacing. replaceCounter: 0, replace: function( dialog, pattern, newString, matchCase, matchWord, matchCyclic, isReplaceAll ) { isReplace = 1; // Successiveness of current replace/find. var result = 0; // 1. Perform the replace when there's already a match here. // 2. Otherwise perform the find but don't replace it immediately. if ( this.matchRange && this.matchRange.isMatched() && !this.matchRange._.isReplaced && !this.matchRange.isReadOnly() ) { // Turn off highlight for a while when saving snapshots. this.matchRange.removeHighlight(); var domRange = this.matchRange.toDomRange(); var text = editor.document.createText( newString ); if ( !isReplaceAll ) { // Save undo snaps before and after the replacement. var selection = editor.getSelection(); selection.selectRanges( [ domRange ] ); editor.fire( 'saveSnapshot' ); } domRange.deleteContents(); domRange.insertNode( text ); if ( !isReplaceAll ) { selection.selectRanges( [ domRange ] ); editor.fire( 'saveSnapshot' ); } this.matchRange.updateFromDomRange( domRange ); if ( !isReplaceAll ) this.matchRange.highlight(); this.matchRange._.isReplaced = true; this.replaceCounter++; result = 1; } else { result = this.find( pattern, matchCase, matchWord, matchCyclic, !isReplaceAll ); } isReplace = 0; return result; } }; // The range in which find/replace happened, receive from user // selection prior. function getSearchRange( isDefault ) { var searchRange, sel = editor.getSelection(), range = sel.getRanges()[ 0 ], editable = editor.editable(); // Blink browsers return empty array of ranges when editor is in read-only mode // and it hasn't got focus, so instead of selection, we check for range itself. (#12848) if ( range && !isDefault ) { searchRange = range.clone(); searchRange.collapse( true ); } else { searchRange = editor.createRange(); searchRange.setStartAt( editable, CKEDITOR.POSITION_AFTER_START ); } searchRange.setEndAt( editable, CKEDITOR.POSITION_BEFORE_END ); return searchRange; } var lang = editor.lang.find; return { title: lang.title, resizable: CKEDITOR.DIALOG_RESIZE_NONE, minWidth: 350, minHeight: 170, buttons: [ // Close button only. CKEDITOR.dialog.cancelButton( editor, { label: editor.lang.common.close } ) ], contents: [ { id: 'find', label: lang.find, title: lang.find, accessKey: '', elements: [ { type: 'hbox', widths: [ '230px', '90px' ], children: [ { type: 'text', id: 'txtFindFind', label: lang.findWhat, isChanged: false, labelLayout: 'horizontal', accessKey: 'F' }, { type: 'button', id: 'btnFind', align: 'left', style: 'width:100%', label: lang.find, onClick: function() { var dialog = this.getDialog(); if ( !finder.find( dialog.getValueOf( 'find', 'txtFindFind' ), dialog.getValueOf( 'find', 'txtFindCaseChk' ), dialog.getValueOf( 'find', 'txtFindWordChk' ), dialog.getValueOf( 'find', 'txtFindCyclic' ) ) ) { alert( lang.notFoundMsg ); // jshint ignore:line } } } ] }, { type: 'fieldset', label: CKEDITOR.tools.htmlEncode( lang.findOptions ), style: 'margin-top:29px', children: [ { type: 'vbox', padding: 0, children: [ { type: 'checkbox', id: 'txtFindCaseChk', isChanged: false, label: lang.matchCase }, { type: 'checkbox', id: 'txtFindWordChk', isChanged: false, label: lang.matchWord }, { type: 'checkbox', id: 'txtFindCyclic', isChanged: false, 'default': true, label: lang.matchCyclic } ] } ] } ] }, { id: 'replace', label: lang.replace, accessKey: 'M', elements: [ { type: 'hbox', widths: [ '230px', '90px' ], children: [ { type: 'text', id: 'txtFindReplace', label: lang.findWhat, isChanged: false, labelLayout: 'horizontal', accessKey: 'F' }, { type: 'button', id: 'btnFindReplace', align: 'left', style: 'width:100%', label: lang.replace, onClick: function() { var dialog = this.getDialog(); if ( !finder.replace( dialog, dialog.getValueOf( 'replace', 'txtFindReplace' ), dialog.getValueOf( 'replace', 'txtReplace' ), dialog.getValueOf( 'replace', 'txtReplaceCaseChk' ), dialog.getValueOf( 'replace', 'txtReplaceWordChk' ), dialog.getValueOf( 'replace', 'txtReplaceCyclic' ) ) ) { alert( lang.notFoundMsg ); // jshint ignore:line } } } ] }, { type: 'hbox', widths: [ '230px', '90px' ], children: [ { type: 'text', id: 'txtReplace', label: lang.replaceWith, isChanged: false, labelLayout: 'horizontal', accessKey: 'R' }, { type: 'button', id: 'btnReplaceAll', align: 'left', style: 'width:100%', label: lang.replaceAll, isChanged: false, onClick: function() { var dialog = this.getDialog(); finder.replaceCounter = 0; // Scope to full document. finder.searchRange = getSearchRange( 1 ); if ( finder.matchRange ) { finder.matchRange.removeHighlight(); finder.matchRange = null; } editor.fire( 'saveSnapshot' ); while ( finder.replace( dialog, dialog.getValueOf( 'replace', 'txtFindReplace' ), dialog.getValueOf( 'replace', 'txtReplace' ), dialog.getValueOf( 'replace', 'txtReplaceCaseChk' ), dialog.getValueOf( 'replace', 'txtReplaceWordChk' ), false, true ) ) { } if ( finder.replaceCounter ) { alert( lang.replaceSuccessMsg.replace( /%1/, finder.replaceCounter ) ); // jshint ignore:line editor.fire( 'saveSnapshot' ); } else { alert( lang.notFoundMsg ); // jshint ignore:line } } } ] }, { type: 'fieldset', label: CKEDITOR.tools.htmlEncode( lang.findOptions ), children: [ { type: 'vbox', padding: 0, children: [ { type: 'checkbox', id: 'txtReplaceCaseChk', isChanged: false, label: lang.matchCase }, { type: 'checkbox', id: 'txtReplaceWordChk', isChanged: false, label: lang.matchWord }, { type: 'checkbox', id: 'txtReplaceCyclic', isChanged: false, 'default': true, label: lang.matchCyclic } ] } ] } ] } ], onLoad: function() { var dialog = this; // Keep track of the current pattern field in use. var patternField, wholeWordChkField; // Ignore initial page select on dialog show var isUserSelect = 0; this.on( 'hide', function() { isUserSelect = 0; } ); this.on( 'show', function() { isUserSelect = 1; } ); this.selectPage = CKEDITOR.tools.override( this.selectPage, function( originalFunc ) { return function( pageId ) { originalFunc.call( dialog, pageId ); var currPage = dialog._.tabs[ pageId ]; var patternFieldInput, patternFieldId, wholeWordChkFieldId; patternFieldId = pageId === 'find' ? 'txtFindFind' : 'txtFindReplace'; wholeWordChkFieldId = pageId === 'find' ? 'txtFindWordChk' : 'txtReplaceWordChk'; patternField = dialog.getContentElement( pageId, patternFieldId ); wholeWordChkField = dialog.getContentElement( pageId, wholeWordChkFieldId ); // Prepare for check pattern text filed 'keyup' event if ( !currPage.initialized ) { patternFieldInput = CKEDITOR.document.getById( patternField._.inputId ); currPage.initialized = true; } // Synchronize fields on tab switch. if ( isUserSelect ) syncFieldsBetweenTabs.call( this, pageId ); }; } ); }, onShow: function() { // Establish initial searching start position. finder.searchRange = getSearchRange(); // Fill in the find field with selected text. var selectedText = this.getParentEditor().getSelection().getSelectedText(), patternFieldId = ( startupPage == 'find' ? 'txtFindFind' : 'txtFindReplace' ); var field = this.getContentElement( startupPage, patternFieldId ); field.setValue( selectedText ); field.select(); this.selectPage( startupPage ); this[ ( startupPage == 'find' && this._.editor.readOnly ? 'hide' : 'show' ) + 'Page' ]( 'replace' ); }, onHide: function() { var range; if ( finder.matchRange && finder.matchRange.isMatched() ) { finder.matchRange.removeHighlight(); editor.focus(); range = finder.matchRange.toDomRange(); if ( range ) editor.getSelection().selectRanges( [ range ] ); } // Clear current session before dialog close delete finder.matchRange; }, onFocus: function() { if ( startupPage == 'replace' ) return this.getContentElement( 'replace', 'txtFindReplace' ); else return this.getContentElement( 'find', 'txtFindFind' ); } }; } CKEDITOR.dialog.add( 'find', function( editor ) { return findDialog( editor, 'find' ); } ); CKEDITOR.dialog.add( 'replace', function( editor ) { return findDialog( editor, 'replace' ); } ); } )();