From 0c376670c65533648618458eaaca56c3c6838ca5 Mon Sep 17 00:00:00 2001 From: kbinieda Date: Sun, 8 Jan 2023 12:11:36 +0100 Subject: [PATCH] Internal (find-and-replace): Bulk find and replace utils into plugin. --- .../src/findandreplaceediting.js | 17 +- .../src/findandreplaceutils.js | 179 ++++++++++++++++++ .../src/findcommand.js | 6 +- .../ckeditor5-find-and-replace/src/index.js | 1 + .../src/replaceallcommand.js | 6 +- .../ckeditor5-find-and-replace/src/utils.js | 166 ---------------- 6 files changed, 199 insertions(+), 176 deletions(-) create mode 100644 packages/ckeditor5-find-and-replace/src/findandreplaceutils.js delete mode 100644 packages/ckeditor5-find-and-replace/src/utils.js diff --git a/packages/ckeditor5-find-and-replace/src/findandreplaceediting.js b/packages/ckeditor5-find-and-replace/src/findandreplaceediting.js index 53b5e76a5af..ec7b3e9777b 100644 --- a/packages/ckeditor5-find-and-replace/src/findandreplaceediting.js +++ b/packages/ckeditor5-find-and-replace/src/findandreplaceediting.js @@ -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'; @@ -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(); @@ -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 ); } ); } @@ -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 */ @@ -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; } diff --git a/packages/ckeditor5-find-and-replace/src/findandreplaceutils.js b/packages/ckeditor5-find-and-replace/src/findandreplaceutils.js new file mode 100644 index 00000000000..9b4a9e71975 --- /dev/null +++ b/packages/ckeditor5-find-and-replace/src/findandreplaceutils.js @@ -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: `` 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 + }; +} diff --git a/packages/ckeditor5-find-and-replace/src/findcommand.js b/packages/ckeditor5-find-and-replace/src/findcommand.js index dffb05f6e77..1daffc4c0ec 100644 --- a/packages/ckeditor5-find-and-replace/src/findcommand.js +++ b/packages/ckeditor5-find-and-replace/src/findcommand.js @@ -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}. @@ -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 { @@ -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, diff --git a/packages/ckeditor5-find-and-replace/src/index.js b/packages/ckeditor5-find-and-replace/src/index.js index 3bd5080b556..fb5fafaecef 100644 --- a/packages/ckeditor5-find-and-replace/src/index.js +++ b/packages/ckeditor5-find-and-replace/src/index.js @@ -8,3 +8,4 @@ */ export { default as FindAndReplace } from './findandreplace'; +export { default as FindAndReplaceUtils } from './findandreplaceutils'; diff --git a/packages/ckeditor5-find-and-replace/src/replaceallcommand.js b/packages/ckeditor5-find-and-replace/src/replaceallcommand.js index 2a6305784e7..aa56bf6ac5a 100644 --- a/packages/ckeditor5-find-and-replace/src/replaceallcommand.js +++ b/packages/ckeditor5-find-and-replace/src/replaceallcommand.js @@ -7,7 +7,6 @@ * @module find-and-replace/replaceallcommand */ -import { updateFindResultFromRange, findByTextCallback } from './utils'; import { Collection } from 'ckeditor5/src/utils'; import ReplaceCommand from './replacecommand'; @@ -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 ); diff --git a/packages/ckeditor5-find-and-replace/src/utils.js b/packages/ckeditor5-find-and-replace/src/utils.js deleted file mode 100644 index bc357ac9316..00000000000 --- a/packages/ckeditor5-find-and-replace/src/utils.js +++ /dev/null @@ -1,166 +0,0 @@ -/** - * @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/utils - */ - -import { uid, Collection } from 'ckeditor5/src/utils'; -import { escapeRegExp } from 'lodash-es'; - -/** - * 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 - * } - * ``` - */ -export function 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: 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. - */ -export function 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: `` 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; - }, '' ); -} - -// 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 - }; -} - -/** - * 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} - */ -export 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; -}