Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Internal (find-and-replace): Bulk find and replace utils into plugin #13226

Merged
merged 1 commit into from
Jan 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions packages/ckeditor5-find-and-replace/src/findandreplaceediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
*/

import { Plugin } from 'ckeditor5/src/core';
import { updateFindResultFromRange } from './utils';
import FindCommand from './findcommand';
import ReplaceCommand from './replacecommand';
import ReplaceAllCommand from './replaceallcommand';
import FindNextCommand from './findnextcommand';
import FindPreviousCommand from './findpreviouscommand';
import FindAndReplaceState from './findandreplacestate';
import FindAndReplaceUtils from './findandreplaceutils';

// eslint-disable-next-line ckeditor5-rules/ckeditor-imports
import { scrollViewportToShowTarget } from '@ckeditor/ckeditor5-utils/src/dom/scroll';
Expand All @@ -26,9 +26,10 @@ import '../theme/findandreplace.css';
const HIGHLIGHT_CLASS = 'ck-find-result_selected';

// Reacts to document changes in order to update search list.
function onDocumentChange( results, model, searchCallback ) {
function onDocumentChange( results, editor, searchCallback ) {
const changedNodes = new Set();
const removedMarkers = new Set();
const model = editor.model;

const changes = model.document.differ.getChanges();

Expand Down Expand Up @@ -73,7 +74,8 @@ function onDocumentChange( results, model, searchCallback ) {

// Run search callback again on updated nodes.
changedNodes.forEach( nodeToCheck => {
updateFindResultFromRange( model.createRangeOn( nodeToCheck ), model, searchCallback, results );
const findAndReplaceUtils = editor.plugins.get( 'FindAndReplaceUtils' );
findAndReplaceUtils.updateFindResultFromRange( model.createRangeOn( nodeToCheck ), model, searchCallback, results );
} );
}

Expand All @@ -83,6 +85,13 @@ function onDocumentChange( results, model, searchCallback ) {
* @extends module:core/plugin~Plugin
*/
export default class FindAndReplaceEditing extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ FindAndReplaceUtils ];
}

/**
* @inheritDoc
*/
Expand Down Expand Up @@ -173,7 +182,7 @@ export default class FindAndReplaceEditing extends Plugin {
this._activeResults = results;

// @todo: handle this listener, another copy is in findcommand.js file.
this.listenTo( model.document, 'change:data', () => onDocumentChange( this._activeResults, model, findCallback ) );
this.listenTo( model.document, 'change:data', () => onDocumentChange( this._activeResults, editor, findCallback ) );

return this._activeResults;
}
Expand Down
179 changes: 179 additions & 0 deletions packages/ckeditor5-find-and-replace/src/findandreplaceutils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/**
* @module find-and-replace/findandreplaceutils
*/

import { Plugin } from 'ckeditor5/src/core';
import { Collection, uid } from 'ckeditor5/src/utils';
import { escapeRegExp } from 'lodash-es';

/**
* A set of helpers related to find and replace.
*/
export default class FindAndReplaceUtils extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'FindAndReplaceUtils';
}

/**
* Executes findCallback and updates search results list.
*
* @param {module:engine/model/range~Range} range The model range to scan for matches.
* @param {module:engine/model/model~Model} model The model.
* @param {Function} findCallback The callback that should return `true` if provided text matches the search term.
* @param {module:utils/collection~Collection} [startResults] An optional collection of find matches that the function should
* start with. This would be a collection returned by a previous `updateFindResultFromRange()` call.
* @returns {module:utils/collection~Collection} A collection of objects describing find match.
*
* An example structure:
*
* ```js
* {
* id: resultId,
* label: foundItem.label,
* marker
* }
* ```
*/
updateFindResultFromRange( range, model, findCallback, startResults ) {
const results = startResults || new Collection();

model.change( writer => {
[ ...range ].forEach( ( { type, item } ) => {
if ( type === 'elementStart' ) {
if ( model.schema.checkChild( item, '$text' ) ) {
const foundItems = findCallback( {
item,
text: this.rangeToText( model.createRangeIn( item ) )
} );

if ( !foundItems ) {
return;
}

foundItems.forEach( foundItem => {
const resultId = `findResult:${ uid() }`;
const marker = writer.addMarker( resultId, {
usingOperation: false,
affectsData: false,
range: writer.createRange(
writer.createPositionAt( item, foundItem.start ),
writer.createPositionAt( item, foundItem.end )
)
} );

const index = findInsertIndex( results, marker );

results.add(
{
id: resultId,
label: foundItem.label,
marker
},
index
);
} );
}
}
} );
} );

return results;
}

/**
* Returns text representation of a range. The returned text length should be the same as range length.
* In order to achieve this, this function will replace inline elements (text-line) as new line character ("\n").
*
* @param {module:engine/model/range~Range} range The model range.
* @returns {String} The text content of the provided range.
*/
rangeToText( range ) {
return Array.from( range.getItems() ).reduce( ( rangeText, node ) => {
// Trim text to a last occurrence of an inline element and update range start.
if ( !( node.is( 'text' ) || node.is( 'textProxy' ) ) ) {
// Editor has only one inline element defined in schema: `<softBreak>` which is treated as new line character in blocks.
// Special handling might be needed for other inline elements (inline widgets).
return `${ rangeText }\n`;
}

return rangeText + node.data;
}, '' );
}

/**
* Creates a text matching callback for a specified search term and matching options.
*
* @param {String} searchTerm The search term.
* @param {Object} [options] Matching options.
* @param {Boolean} [options.matchCase=false] If set to `true` letter casing will be ignored.
* @param {Boolean} [options.wholeWords=false] If set to `true` only whole words that match `callbackOrText` will be matched.
* @returns {Function}
*/
findByTextCallback( searchTerm, options ) {
let flags = 'gu';

if ( !options.matchCase ) {
flags += 'i';
}

let regExpQuery = `(${ escapeRegExp( searchTerm ) })`;

if ( options.wholeWords ) {
const nonLetterGroup = '[^a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]';

if ( !new RegExp( '^' + nonLetterGroup ).test( searchTerm ) ) {
regExpQuery = `(^|${ nonLetterGroup }|_)${ regExpQuery }`;
}

if ( !new RegExp( nonLetterGroup + '$' ).test( searchTerm ) ) {
regExpQuery = `${ regExpQuery }(?=_|${ nonLetterGroup }|$)`;
}
}

const regExp = new RegExp( regExpQuery, flags );

function findCallback( { text } ) {
const matches = [ ...text.matchAll( regExp ) ];

return matches.map( regexpMatchToFindResult );
}

return findCallback;
}
}

// Finds the appropriate index in the resultsList Collection.
function findInsertIndex( resultsList, markerToInsert ) {
const result = resultsList.find( ( { marker } ) => {
return markerToInsert.getStart().isBefore( marker.getStart() );
} );

return result ? resultsList.getIndex( result ) : resultsList.length;
}

// Maps RegExp match result to find result.
function regexpMatchToFindResult( matchResult ) {
const lastGroupIndex = matchResult.length - 1;

let startOffset = matchResult.index;

// Searches with match all flag have an extra matching group with empty string or white space matched before the word.
// If the search term starts with the space already, there is no extra group even with match all flag on.
if ( matchResult.length === 3 ) {
startOffset += matchResult[ 1 ].length;
}

return {
label: matchResult[ lastGroupIndex ],
start: startOffset,
end: startOffset + matchResult[ lastGroupIndex ].length
};
}
6 changes: 3 additions & 3 deletions packages/ckeditor5-find-and-replace/src/findcommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
*/

import { Command } from 'ckeditor5/src/core';
import { updateFindResultFromRange, findByTextCallback } from './utils';

/**
* The find command. It is used by the {@link module:find-and-replace/findandreplace~FindAndReplace find and replace feature}.
Expand Down Expand Up @@ -53,12 +52,13 @@ export default class FindCommand extends Command {
execute( callbackOrText, { matchCase, wholeWords } = {} ) {
const { editor } = this;
const { model } = editor;
const findAndReplaceUtils = editor.plugins.get( 'FindAndReplaceUtils' );

let findCallback;

// Allow to execute `find()` on a plugin with a keyword only.
if ( typeof callbackOrText === 'string' ) {
findCallback = findByTextCallback( callbackOrText, { matchCase, wholeWords } );
findCallback = findAndReplaceUtils.findByTextCallback( callbackOrText, { matchCase, wholeWords } );

this._state.searchText = callbackOrText;
} else {
Expand All @@ -67,7 +67,7 @@ export default class FindCommand extends Command {

// Initial search is done on all nodes in all roots inside the content.
const results = model.document.getRootNames()
.reduce( ( ( currentResults, rootName ) => updateFindResultFromRange(
.reduce( ( ( currentResults, rootName ) => findAndReplaceUtils.updateFindResultFromRange(
model.createRangeIn( model.document.getRoot( rootName ) ),
model,
findCallback,
Expand Down
1 change: 1 addition & 0 deletions packages/ckeditor5-find-and-replace/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
*/

export { default as FindAndReplace } from './findandreplace';
export { default as FindAndReplaceUtils } from './findandreplaceutils';
6 changes: 3 additions & 3 deletions packages/ckeditor5-find-and-replace/src/replaceallcommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
* @module find-and-replace/replaceallcommand
*/

import { updateFindResultFromRange, findByTextCallback } from './utils';
import { Collection } from 'ckeditor5/src/utils';
import ReplaceCommand from './replacecommand';

Expand Down Expand Up @@ -39,13 +38,14 @@ export default class ReplaceAllCommand extends ReplaceCommand {
execute( newText, textToReplace ) {
const { editor } = this;
const { model } = editor;
const findAndReplaceUtils = editor.plugins.get( 'FindAndReplaceUtils' );

const results = textToReplace instanceof Collection ?
textToReplace : model.document.getRootNames()
.reduce( ( ( currentResults, rootName ) => updateFindResultFromRange(
.reduce( ( ( currentResults, rootName ) => findAndReplaceUtils.updateFindResultFromRange(
model.createRangeIn( model.document.getRoot( rootName ) ),
model,
findByTextCallback( textToReplace, this._state ),
findAndReplaceUtils.findByTextCallback( textToReplace, this._state ),
currentResults
) ), null );

Expand Down
Loading