@ckeditor/ckeditor5-find-and-replace
Version:
Find and replace feature for CKEditor 5.
1,206 lines (1,196 loc) • 64.9 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Plugin, Command } from '@ckeditor/ckeditor5-core/dist/index.js';
import { IconPreviousArrow, IconFindReplace } from '@ckeditor/ckeditor5-icons/dist/index.js';
import { View, ViewCollection, FocusCycler, submitHandler, CollapsibleView, SwitchButtonView, ButtonView, LabeledFieldView, createLabeledInputText, Dialog, DropdownView, createDropdown, FormHeaderView, MenuBarMenuListItemButtonView, DialogViewPosition, CssTransitionDisablerMixin } from '@ckeditor/ckeditor5-ui/dist/index.js';
import { FocusTracker, KeystrokeHandler, isVisible, Rect, ObservableMixin, Collection, uid, scrollViewportToShowTarget } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { escapeRegExp, debounce } from 'es-toolkit/compat';
/**
* The find and replace form view class.
*
* See {@link module:find-and-replace/ui/findandreplaceformview~FindAndReplaceFormView}.
*/ class FindAndReplaceFormView extends View {
/**
* A collection of child views.
*/ children;
/**
* The find in text input view that stores the searched string.
*
* @internal
*/ _findInputView;
/**
* The replace input view.
*/ _replaceInputView;
/**
* The find button view that initializes the search process.
*/ _findButtonView;
/**
* The find previous button view.
*/ _findPrevButtonView;
/**
* The find next button view.
*/ _findNextButtonView;
/**
* A collapsible view aggregating the advanced search options.
*/ _advancedOptionsCollapsibleView;
/**
* A switch button view controlling the "Match case" option.
*/ _matchCaseSwitchView;
/**
* A switch button view controlling the "Whole words only" option.
*/ _wholeWordsOnlySwitchView;
/**
* The replace button view.
*/ _replaceButtonView;
/**
* The replace all button view.
*/ _replaceAllButtonView;
/**
* The `div` aggregating the inputs.
*/ _inputsDivView;
/**
* The `div` aggregating the action buttons.
*/ _actionButtonsDivView;
/**
* Tracks information about the DOM focus in the form.
*/ _focusTracker;
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*/ _keystrokes;
/**
* A collection of views that can be focused in the form.
*/ _focusables;
/**
* Helps cycling over {@link #_focusables} in the form.
*/ focusCycler;
/**
* Creates a view of find and replace form.
*
* @param locale The localization services instance.
*/ constructor(locale){
super(locale);
const t = locale.t;
this.children = this.createCollection();
this.set('matchCount', 0);
this.set('highlightOffset', 0);
this.set('isDirty', false);
this.set('_areCommandsEnabled', {});
this.set('_resultsCounterText', '');
this.set('_matchCase', false);
this.set('_wholeWordsOnly', false);
this.bind('_searchResultsFound').to(this, 'matchCount', this, 'isDirty', (matchCount, isDirty)=>{
return matchCount > 0 && !isDirty;
});
this._findInputView = this._createInputField(t('Find in text…'));
this._findPrevButtonView = this._createButton({
label: t('Previous result'),
class: 'ck-button-prev',
icon: IconPreviousArrow,
keystroke: 'Shift+F3',
tooltip: true
});
this._findNextButtonView = this._createButton({
label: t('Next result'),
class: 'ck-button-next',
icon: IconPreviousArrow,
keystroke: 'F3',
tooltip: true
});
this._replaceInputView = this._createInputField(t('Replace with…'), 'ck-labeled-field-replace');
this._inputsDivView = this._createInputsDiv();
this._matchCaseSwitchView = this._createMatchCaseSwitch();
this._wholeWordsOnlySwitchView = this._createWholeWordsOnlySwitch();
this._advancedOptionsCollapsibleView = this._createAdvancedOptionsCollapsible();
this._replaceAllButtonView = this._createButton({
label: t('Replace all'),
class: 'ck-button-replaceall',
withText: true
});
this._replaceButtonView = this._createButton({
label: t('Replace'),
class: 'ck-button-replace',
withText: true
});
this._findButtonView = this._createButton({
label: t('Find'),
class: 'ck-button-find ck-button-action',
withText: true
});
this._actionButtonsDivView = this._createActionButtonsDiv();
this._focusTracker = new FocusTracker();
this._keystrokes = new KeystrokeHandler();
this._focusables = new ViewCollection();
this.focusCycler = new FocusCycler({
focusables: this._focusables,
focusTracker: this._focusTracker,
keystrokeHandler: this._keystrokes,
actions: {
// Navigate form fields backwards using the <kbd>Shift</kbd> + <kbd>Tab</kbd> keystroke.
focusPrevious: 'shift + tab',
// Navigate form fields forwards using the <kbd>Tab</kbd> key.
focusNext: 'tab'
}
});
this.children.addMany([
this._inputsDivView,
this._advancedOptionsCollapsibleView,
this._actionButtonsDivView
]);
this.setTemplate({
tag: 'form',
attributes: {
class: [
'ck',
'ck-find-and-replace-form'
],
tabindex: '-1'
},
children: this.children
});
}
/**
* @inheritDoc
*/ render() {
super.render();
submitHandler({
view: this
});
this._initFocusCycling();
this._initKeystrokeHandling();
}
/**
* @inheritDoc
*/ destroy() {
super.destroy();
this._focusTracker.destroy();
this._keystrokes.destroy();
}
/**
* @inheritDoc
*/ focus(direction) {
if (direction === -1) {
this.focusCycler.focusLast();
} else {
this.focusCycler.focusFirst();
}
}
/**
* Resets the form before re-appearing.
*
* It clears error messages, hides the match counter and disables the replace feature
* until the next hit of the "Find" button.
*
* **Note**: It does not reset inputs and options, though. This way the form works better in editors with
* disappearing toolbar (e.g. BalloonEditor): hiding the toolbar by accident (together with the find and replace UI)
* does not require filling the entire form again.
*/ reset() {
this._findInputView.errorText = null;
this.isDirty = true;
}
/**
* Returns the value of the find input.
*/ get _textToFind() {
return this._findInputView.fieldView.element.value;
}
/**
* Returns the value of the replace input.
*/ get _textToReplace() {
return this._replaceInputView.fieldView.element.value;
}
/**
* Configures and returns the `<div>` aggregating all form inputs.
*/ _createInputsDiv() {
const locale = this.locale;
const t = locale.t;
const inputsDivView = new View(locale);
// Typing in the find field invalidates all previous results (the form is "dirty").
this._findInputView.fieldView.on('input', ()=>{
this.isDirty = true;
});
// Pressing prev/next buttons fires related event on the form.
this._findPrevButtonView.delegate('execute').to(this, 'findPrevious');
this._findNextButtonView.delegate('execute').to(this, 'findNext');
// Prev/next buttons will be disabled when related editor command gets disabled.
this._findPrevButtonView.bind('isEnabled').to(this, '_areCommandsEnabled', ({ findPrevious })=>findPrevious);
this._findNextButtonView.bind('isEnabled').to(this, '_areCommandsEnabled', ({ findNext })=>findNext);
this._injectFindResultsCounter();
this._replaceInputView.bind('isEnabled').to(this, '_areCommandsEnabled', this, '_searchResultsFound', ({ replace }, resultsFound)=>replace && resultsFound);
this._replaceInputView.bind('infoText').to(this._replaceInputView, 'isEnabled', this._replaceInputView, 'isFocused', (isEnabled, isFocused)=>{
if (isEnabled || !isFocused) {
return '';
}
return t('Tip: Find some text first in order to replace it.');
});
inputsDivView.setTemplate({
tag: 'div',
attributes: {
class: [
'ck',
'ck-find-and-replace-form__inputs'
]
},
children: [
this._findInputView,
this._findPrevButtonView,
this._findNextButtonView,
this._replaceInputView
]
});
return inputsDivView;
}
/**
* The action performed when the {@link #_findButtonView} is pressed.
*/ _onFindButtonExecute() {
// When hitting "Find" in an empty input, an error should be displayed.
// Also, if the form was "dirty", it should remain so.
if (!this._textToFind) {
const t = this.t;
this._findInputView.errorText = t('Text to find must not be empty.');
return;
}
// Hitting "Find" automatically clears the dirty state.
this.isDirty = false;
this.fire('findNext', {
searchText: this._textToFind,
matchCase: this._matchCase,
wholeWords: this._wholeWordsOnly
});
}
/**
* Configures an injects the find results counter displaying a "N of M" label of the {@link #_findInputView}.
*/ _injectFindResultsCounter() {
const locale = this.locale;
const t = locale.t;
const bind = this.bindTemplate;
const resultsCounterView = new View(this.locale);
this.bind('_resultsCounterText').to(this, 'highlightOffset', this, 'matchCount', (highlightOffset, matchCount)=>t('%0 of %1', [
highlightOffset,
matchCount
]));
resultsCounterView.setTemplate({
tag: 'span',
attributes: {
class: [
'ck',
'ck-results-counter',
// The counter only makes sense when the field text corresponds to search results in the editing.
bind.if('isDirty', 'ck-hidden')
]
},
children: [
{
text: bind.to('_resultsCounterText')
}
]
});
// The whole idea is that when the text of the counter changes, its width also increases/decreases and
// it consumes more or less space over the input. The input, on the other hand, should adjust it's right
// padding so its *entire* text always remains visible and available to the user.
const updateFindInputPadding = ()=>{
const inputElement = this._findInputView.fieldView.element;
// Don't adjust the padding if the input (also: counter) were not rendered or not inserted into DOM yet.
if (!inputElement || !isVisible(inputElement)) {
return;
}
const counterWidth = new Rect(resultsCounterView.element).width;
const paddingPropertyName = locale.uiLanguageDirection === 'ltr' ? 'paddingRight' : 'paddingLeft';
if (!counterWidth) {
inputElement.style[paddingPropertyName] = '';
} else {
inputElement.style[paddingPropertyName] = `calc( 2 * var(--ck-spacing-standard) + ${counterWidth}px )`;
}
};
// Adjust the input padding when the text of the counter changes, for instance "1 of 200" is narrower than "123 of 200".
// Using "low" priority to let the text be set by the template binding first.
this.on('change:_resultsCounterText', updateFindInputPadding, {
priority: 'low'
});
// Adjust the input padding when the counter shows or hides. When hidden, there should be no padding. When it shows, the
// padding should be set according to the text of the counter.
// Using "low" priority to let the text be set by the template binding first.
this.on('change:isDirty', updateFindInputPadding, {
priority: 'low'
});
// Put the counter element next to the <input> in the find field.
this._findInputView.template.children[0].children.push(resultsCounterView);
}
/**
* Creates the collapsible view aggregating the advanced search options.
*/ _createAdvancedOptionsCollapsible() {
const t = this.locale.t;
const collapsible = new CollapsibleView(this.locale, [
this._matchCaseSwitchView,
this._wholeWordsOnlySwitchView
]);
collapsible.set({
label: t('Advanced options'),
isCollapsed: true
});
return collapsible;
}
/**
* Configures and returns the `<div>` element aggregating all form action buttons.
*/ _createActionButtonsDiv() {
const actionsDivView = new View(this.locale);
this._replaceButtonView.bind('isEnabled').to(this, '_areCommandsEnabled', this, '_searchResultsFound', ({ replace }, resultsFound)=>replace && resultsFound);
this._replaceAllButtonView.bind('isEnabled').to(this, '_areCommandsEnabled', this, '_searchResultsFound', ({ replaceAll }, resultsFound)=>replaceAll && resultsFound);
this._replaceButtonView.on('execute', ()=>{
this.fire('replace', {
searchText: this._textToFind,
replaceText: this._textToReplace
});
});
this._replaceAllButtonView.on('execute', ()=>{
this.fire('replaceAll', {
searchText: this._textToFind,
replaceText: this._textToReplace
});
this.focus();
});
this._findButtonView.on('execute', this._onFindButtonExecute.bind(this));
actionsDivView.setTemplate({
tag: 'div',
attributes: {
class: [
'ck',
'ck-find-and-replace-form__actions'
]
},
children: [
this._replaceAllButtonView,
this._replaceButtonView,
this._findButtonView
]
});
return actionsDivView;
}
/**
* Creates, configures and returns and instance of a dropdown allowing users to narrow
* the search criteria down. The dropdown has a list with switch buttons for each option.
*/ _createMatchCaseSwitch() {
const t = this.locale.t;
const matchCaseSwitchButton = new SwitchButtonView(this.locale);
matchCaseSwitchButton.set({
label: t('Match case'),
withText: true
});
// Let the switch be controlled by form's observable property.
matchCaseSwitchButton.bind('isOn').to(this, '_matchCase');
// // Update the state of the form when a switch is toggled.
matchCaseSwitchButton.on('execute', ()=>{
this._matchCase = !this._matchCase;
// Toggling a switch makes the form dirty because this changes search criteria
// just like typing text of the find input.
this.isDirty = true;
});
return matchCaseSwitchButton;
}
/**
* Creates, configures and returns and instance of a dropdown allowing users to narrow
* the search criteria down. The dropdown has a list with switch buttons for each option.
*/ _createWholeWordsOnlySwitch() {
const t = this.locale.t;
const wholeWordsOnlySwitchButton = new SwitchButtonView(this.locale);
wholeWordsOnlySwitchButton.set({
label: t('Whole words only'),
withText: true
});
// Let the switch be controlled by form's observable property.
wholeWordsOnlySwitchButton.bind('isOn').to(this, '_wholeWordsOnly');
// // Update the state of the form when a switch is toggled.
wholeWordsOnlySwitchButton.on('execute', ()=>{
this._wholeWordsOnly = !this._wholeWordsOnly;
// Toggling a switch makes the form dirty because this changes search criteria
// just like typing text of the find input.
this.isDirty = true;
});
return wholeWordsOnlySwitchButton;
}
/**
* Initializes the {@link #_focusables} and {@link #_focusTracker} to allow navigation
* using <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> keystrokes in the right order.
*/ _initFocusCycling() {
const childViews = [
this._findInputView,
this._findPrevButtonView,
this._findNextButtonView,
this._replaceInputView,
this._advancedOptionsCollapsibleView.buttonView,
this._matchCaseSwitchView,
this._wholeWordsOnlySwitchView,
this._replaceAllButtonView,
this._replaceButtonView,
this._findButtonView
];
childViews.forEach((v)=>{
// Register the view as focusable.
this._focusables.add(v);
// Register the view in the focus tracker.
this._focusTracker.add(v.element);
});
}
/**
* Initializes the keystroke handling in the form.
*/ _initKeystrokeHandling() {
const stopPropagation = (data)=>data.stopPropagation();
const stopPropagationAndPreventDefault = (data)=>{
data.stopPropagation();
data.preventDefault();
};
// Start listening for the keystrokes coming from #element.
this._keystrokes.listenTo(this.element);
// Find the next result upon F3.
this._keystrokes.set('f3', (event)=>{
stopPropagationAndPreventDefault(event);
this._findNextButtonView.fire('execute');
});
// Find the previous result upon F3.
this._keystrokes.set('shift+f3', (event)=>{
stopPropagationAndPreventDefault(event);
this._findPrevButtonView.fire('execute');
});
// Find or replace upon pressing Enter in the find and replace fields.
this._keystrokes.set('enter', (event)=>{
const target = event.target;
if (target === this._findInputView.fieldView.element) {
if (this._areCommandsEnabled.findNext) {
this._findNextButtonView.fire('execute');
} else {
this._findButtonView.fire('execute');
}
stopPropagationAndPreventDefault(event);
} else if (target === this._replaceInputView.fieldView.element && !this.isDirty) {
this._replaceButtonView.fire('execute');
stopPropagationAndPreventDefault(event);
}
});
// Find previous upon pressing Shift+Enter in the find field.
this._keystrokes.set('shift+enter', (event)=>{
const target = event.target;
if (target !== this._findInputView.fieldView.element) {
return;
}
if (this._areCommandsEnabled.findPrevious) {
this._findPrevButtonView.fire('execute');
} else {
this._findButtonView.fire('execute');
}
stopPropagationAndPreventDefault(event);
});
// Since the form is in the dropdown panel which is a child of the toolbar, the toolbar's
// keystroke handler would take over the key management in the URL input.
// We need to prevent this ASAP. Otherwise, the basic caret movement using the arrow keys will be impossible.
this._keystrokes.set('arrowright', stopPropagation);
this._keystrokes.set('arrowleft', stopPropagation);
this._keystrokes.set('arrowup', stopPropagation);
this._keystrokes.set('arrowdown', stopPropagation);
}
/**
* Creates a button view.
*
* @param options The properties of the `ButtonView`.
* @returns The button view instance.
*/ _createButton(options) {
const button = new ButtonView(this.locale);
button.set(options);
return button;
}
/**
* Creates a labeled input view.
*
* @param label The input label.
* @returns The labeled input view instance.
*/ _createInputField(label, className) {
const labeledInput = new LabeledFieldView(this.locale, createLabeledInputText);
labeledInput.label = label;
labeledInput.class = className;
return labeledInput;
}
}
/**
* The default find and replace UI.
*
* It registers the `'findAndReplace'` UI button in the editor's {@link module:ui/componentfactory~ComponentFactory component factory}.
* that uses the {@link module:find-and-replace/findandreplace~FindAndReplace FindAndReplace} plugin API.
*/ class FindAndReplaceUI extends Plugin {
/**
* @inheritDoc
*/ static get requires() {
return [
Dialog
];
}
/**
* @inheritDoc
*/ static get pluginName() {
return 'FindAndReplaceUI';
}
/**
* @inheritDoc
*/ static get isOfficialPlugin() {
return true;
}
/**
* A reference to the find and replace form view.
*/ formView;
/**
* @inheritDoc
*/ constructor(editor){
super(editor);
editor.config.define('findAndReplace.uiType', 'dialog');
this.formView = null;
}
/**
* @inheritDoc
*/ init() {
const editor = this.editor;
const isUiUsingDropdown = editor.config.get('findAndReplace.uiType') === 'dropdown';
const findCommand = editor.commands.get('find');
const t = this.editor.t;
// Register the toolbar component: dropdown or button (that opens a dialog).
editor.ui.componentFactory.add('findAndReplace', ()=>{
let view;
if (isUiUsingDropdown) {
view = this._createDropdown();
// Button should be disabled when in source editing mode. See #10001.
view.bind('isEnabled').to(findCommand);
} else {
view = this._createDialogButtonForToolbar();
}
editor.keystrokes.set('Ctrl+F', (data, cancelEvent)=>{
if (!findCommand.isEnabled) {
return;
}
if (view instanceof DropdownView) {
const dropdownButtonView = view.buttonView;
if (!dropdownButtonView.isOn) {
dropdownButtonView.fire('execute');
}
} else {
if (view.isOn) {
// If the dialog is open, do not close it. Instead focus it.
// Unfortunately we can't simply use:
// this.formView!.focus();
// because it would always move focus to the first input field, which we don't want.
editor.plugins.get('Dialog').view.focus();
} else {
view.fire('execute');
}
}
cancelEvent();
});
return view;
});
if (!isUiUsingDropdown) {
editor.ui.componentFactory.add('menuBar:findAndReplace', ()=>{
return this._createDialogButtonForMenuBar();
});
}
// Add the information about the keystroke to the accessibility database.
editor.accessibility.addKeystrokeInfos({
keystrokes: [
{
label: t('Find in the document'),
keystroke: 'CTRL+F'
}
]
});
}
/**
* Creates a dropdown containing the find and replace form.
*/ _createDropdown() {
const editor = this.editor;
const t = editor.locale.t;
const dropdownView = createDropdown(editor.locale);
dropdownView.once('change:isOpen', ()=>{
this.formView = this._createFormView();
this.formView.children.add(new FormHeaderView(editor.locale, {
label: t('Find and replace')
}), 0);
dropdownView.panelView.children.add(this.formView);
});
// Every time a dropdown is opened, the search text field should get focused and selected for better UX.
// Note: Using the low priority here to make sure the following listener starts working after
// the default action of the drop-down is executed (i.e. the panel showed up). Otherwise,
// the invisible form/input cannot be focused/selected.
//
// Each time a dropdown is closed, move the focus back to the find and replace toolbar button
// and let the find and replace editing feature know that all search results can be invalidated
// and no longer should be marked in the content.
dropdownView.on('change:isOpen', (event, name, isOpen)=>{
if (isOpen) {
this._setupFormView();
} else {
this.fire('searchReseted');
}
}, {
priority: 'low'
});
dropdownView.buttonView.set({
icon: IconFindReplace,
label: t('Find and replace'),
keystroke: 'CTRL+F',
tooltip: true
});
return dropdownView;
}
/**
* Creates a button that opens a dialog with the find and replace form.
*/ _createDialogButtonForToolbar() {
const editor = this.editor;
const buttonView = this._createButton(ButtonView);
const dialog = editor.plugins.get('Dialog');
buttonView.set({
tooltip: true
});
// Button should be on when the find and replace dialog is opened.
buttonView.bind('isOn').to(dialog, 'id', (id)=>id === 'findAndReplace');
// Every time a dialog is opened, the search text field should get focused and selected for better UX.
// Each time a dialog is closed, move the focus back to the find and replace toolbar button
// and let the find and replace editing feature know that all search results can be invalidated
// and no longer should be marked in the content.
buttonView.on('execute', ()=>{
if (buttonView.isOn) {
dialog.hide();
} else {
this._showDialog();
}
});
return buttonView;
}
/**
* Creates a button for for menu bar that will show find and replace dialog.
*/ _createDialogButtonForMenuBar() {
const buttonView = this._createButton(MenuBarMenuListItemButtonView);
const dialogPlugin = this.editor.plugins.get('Dialog');
const dialog = this.editor.plugins.get('Dialog');
buttonView.set({
role: 'menuitemcheckbox',
isToggleable: true
});
// Button should be on when the find and replace dialog is opened.
buttonView.bind('isOn').to(dialog, 'id', (id)=>id === 'findAndReplace');
buttonView.on('execute', ()=>{
if (dialogPlugin.id === 'findAndReplace') {
dialogPlugin.hide();
return;
}
this._showDialog();
});
return buttonView;
}
/**
* Creates a button for find and replace command to use either in toolbar or in menu bar.
*/ _createButton(ButtonClass) {
const editor = this.editor;
const findCommand = editor.commands.get('find');
const buttonView = new ButtonClass(editor.locale);
const t = editor.locale.t;
// Button should be disabled when in source editing mode. See #10001.
buttonView.bind('isEnabled').to(findCommand);
buttonView.set({
icon: IconFindReplace,
label: t('Find and replace'),
keystroke: 'CTRL+F'
});
return buttonView;
}
/**
* Shows the find and replace dialog.
*/ _showDialog() {
const editor = this.editor;
const dialog = editor.plugins.get('Dialog');
const t = editor.locale.t;
if (!this.formView) {
this.formView = this._createFormView();
}
dialog.show({
id: 'findAndReplace',
title: t('Find and replace'),
content: this.formView,
position: DialogViewPosition.EDITOR_TOP_SIDE,
onShow: ()=>{
this._setupFormView();
},
onHide: ()=>{
this.fire('searchReseted');
}
});
}
/**
* Sets up the form view for the findN and replace.
*/ _createFormView() {
const editor = this.editor;
const formView = new (CssTransitionDisablerMixin(FindAndReplaceFormView))(editor.locale);
const commands = editor.commands;
const findAndReplaceEditing = this.editor.plugins.get('FindAndReplaceEditing');
const editingState = findAndReplaceEditing.state;
formView.bind('highlightOffset').to(editingState, 'highlightedOffset');
// Let the form know how many results were found in total.
formView.listenTo(editingState.results, 'change', ()=>{
formView.matchCount = editingState.results.length;
});
// Command states are used to enable/disable individual form controls.
// To keep things simple, instead of binding 4 individual observables, there's only one that combines every
// commands' isEnabled state. Yes, it will change more often but this simplifies the structure of the form.
const findNextCommand = commands.get('findNext');
const findPreviousCommand = commands.get('findPrevious');
const replaceCommand = commands.get('replace');
const replaceAllCommand = commands.get('replaceAll');
formView.bind('_areCommandsEnabled').to(findNextCommand, 'isEnabled', findPreviousCommand, 'isEnabled', replaceCommand, 'isEnabled', replaceAllCommand, 'isEnabled', (findNext, findPrevious, replace, replaceAll)=>({
findNext,
findPrevious,
replace,
replaceAll
}));
// The UI plugin works as an interface between the form and the editing part of the feature.
formView.delegate('findNext', 'findPrevious', 'replace', 'replaceAll').to(this);
// Let the feature know that search results are no longer relevant because the user changed the searched phrase
// (or options) but didn't hit the "Find" button yet (e.g. still typing).
formView.on('change:isDirty', (evt, data, isDirty)=>{
if (isDirty) {
this.fire('searchReseted');
}
});
return formView;
}
/**
* Clears the find and replace form and focuses the search text field.
*/ _setupFormView() {
this.formView.disableCssTransitions();
this.formView.reset();
this.formView._findInputView.fieldView.select();
this.formView.enableCssTransitions();
}
}
/**
* The find command. It is used by the {@link module:find-and-replace/findandreplace~FindAndReplace find and replace feature}.
*/ class FindCommand extends Command {
/**
* The find and replace state object used for command operations.
*/ _state;
/**
* Creates a new `FindCommand` instance.
*
* @param editor The editor on which this command will be used.
* @param state An object to hold plugin state.
*/ constructor(editor, state){
super(editor);
// The find command is always enabled.
this.isEnabled = true;
// It does not affect data so should be enabled in read-only mode.
this.affectsData = false;
this._state = state;
}
/**
* Executes the command.
*
* @param callbackOrText
* @param options Options object.
* @param options.matchCase If set to `true`, the letter case will be matched.
* @param options.wholeWords If set to `true`, only whole words that match `callbackOrText` will be matched.
*
* @fires execute
*/ execute(callbackOrText, { matchCase, wholeWords } = {}) {
const { editor } = this;
const { model } = editor;
const findAndReplaceUtils = editor.plugins.get('FindAndReplaceUtils');
let findCallback;
let callbackSearchText = '';
// Allow to execute `find()` on a plugin with a keyword only.
if (typeof callbackOrText === 'string') {
findCallback = (...args)=>({
results: findAndReplaceUtils.findByTextCallback(callbackOrText, {
matchCase,
wholeWords
})(...args),
searchText: callbackOrText
});
} else {
findCallback = callbackOrText;
}
// Wrap the callback to get the search text that will be assigned to the state.
const oldCallback = findCallback;
findCallback = (...args)=>{
const result = oldCallback(...args);
if (result && 'searchText' in result) {
callbackSearchText = result.searchText;
}
return result;
};
// Initial search is done on all nodes in all roots inside the content.
const results = model.document.getRootNames().reduce((currentResults, rootName)=>findAndReplaceUtils.updateFindResultFromRange(model.createRangeIn(model.document.getRoot(rootName)), model, findCallback, currentResults), null);
this._state.clear(model);
this._state.results.addMany(results);
this._state.highlightedResult = results.get(0);
this._state.searchText = callbackSearchText;
if (findCallback) {
this._state.lastSearchCallback = findCallback;
}
this._state.matchCase = !!matchCase;
this._state.matchWholeWords = !!wholeWords;
return {
results,
findCallback
};
}
}
/**
* The object storing find and replace plugin state for a given editor instance.
*/ class FindAndReplaceState extends /* #__PURE__ */ ObservableMixin() {
/**
* Creates an instance of the state.
*/ constructor(model){
super();
this.set('results', new Collection());
this.set('highlightedResult', null);
this.set('highlightedOffset', 0);
this.set('searchText', '');
this.set('replaceText', '');
this.set('lastSearchCallback', null);
this.set('matchCase', false);
this.set('matchWholeWords', false);
this.results.on('change', (eventInfo, { removed, index })=>{
if (Array.from(removed).length) {
let highlightedResultRemoved = false;
model.change((writer)=>{
for (const removedResult of removed){
if (this.highlightedResult === removedResult) {
highlightedResultRemoved = true;
}
if (model.markers.has(removedResult.marker.name)) {
writer.removeMarker(removedResult.marker);
}
}
});
if (highlightedResultRemoved) {
const nextHighlightedIndex = index >= this.results.length ? 0 : index;
this.highlightedResult = this.results.get(nextHighlightedIndex);
}
}
});
this.on('change:highlightedResult', ()=>{
this.refreshHighlightOffset(model);
});
}
/**
* Cleans the state up and removes markers from the model.
*/ clear(model) {
this.searchText = '';
model.change((writer)=>{
if (this.highlightedResult) {
const oldMatchId = this.highlightedResult.marker.name.split(':')[1];
const oldMarker = model.markers.get(`findResultHighlighted:${oldMatchId}`);
if (oldMarker) {
writer.removeMarker(oldMarker);
}
}
[
...this.results
].forEach(({ marker })=>{
writer.removeMarker(marker);
});
});
this.results.clear();
}
/**
* Refreshes the highlight result offset based on it's index within the result list.
*/ refreshHighlightOffset(model) {
const { highlightedResult, results } = this;
if (highlightedResult) {
this.highlightedOffset = sortSearchResultsByMarkerPositions(model, [
...results
]).indexOf(highlightedResult) + 1;
} else {
this.highlightedOffset = 0;
}
}
}
/**
* Sorts search results by marker positions. Make sure that the results are sorted in the same order as they appear in the document
* to avoid issues with the `find next` command. Apparently, the order of the results in the state might be different than the order
* of the markers in the model.
*/ function sortSearchResultsByMarkerPositions(model, results) {
const sortMapping = {
before: -1,
same: 0,
after: 1,
different: 1
};
// `compareWith` doesn't play well with multi-root documents, so we need to sort results by root name first
// and then sort them within each root. It prevents "random" order of results when the document has multiple roots.
// See more: https://github.com/ckeditor/ckeditor5/pull/17292#issuecomment-2442084549
return model.document.getRootNames().flatMap((rootName)=>results.filter((result)=>result.marker.getStart().root.rootName === rootName).sort((a, b)=>sortMapping[a.marker.getStart().compareWith(b.marker.getStart())]));
}
class ReplaceCommandBase extends Command {
/**
* The find and replace state object used for command operations.
*/ _state;
/**
* Creates a new `ReplaceCommand` instance.
*
* @param editor Editor on which this command will be used.
* @param state An object to hold plugin state.
*/ constructor(editor, state){
super(editor);
// The replace command is always enabled.
this.isEnabled = true;
this._state = state;
// Since this command executes on particular result independent of selection, it should be checked directly in execute block.
this._isEnabledBasedOnSelection = false;
}
/**
* Common logic for both `replace` commands.
* Replace a given find result by a string or a callback.
*
* @param result A single result from the find command.
*/ _replace(replacementText, result) {
const { model } = this.editor;
const range = result.marker.getRange();
// Don't replace a result that is in non-editable place.
if (!model.canEditAt(range)) {
return;
}
model.change((writer)=>{
// Don't replace a result (marker) that found its way into the $graveyard (e.g. removed by collaborators).
if (range.root.rootName === '$graveyard') {
this._state.results.remove(result);
return;
}
let textAttributes = {};
for (const item of range.getItems()){
if (item.is('$text') || item.is('$textProxy')) {
textAttributes = item.getAttributes();
break;
}
}
model.insertContent(writer.createText(replacementText, textAttributes), range);
if (this._state.results.has(result)) {
this._state.results.remove(result);
}
});
}
}
/**
* The replace command. It is used by the {@link module:find-and-replace/findandreplace~FindAndReplace find and replace feature}.
*/ class ReplaceCommand extends ReplaceCommandBase {
/**
* Replace a given find result by a string or a callback.
*
* @param result A single result from the find command.
*
* @fires execute
*/ execute(replacementText, result) {
// We save highlight offset here, as the information about the highlighted result will be lost after the changes.
//
// It happens because result list is partially regenerated if the result is removed from the paragraph.
// Partially means that all sibling result items that are placed in the same paragraph are removed and added again,
// which causes the highlighted result to be malformed (usually it's set to first but it's not guaranteed).
//
// While this saving can be done in editing state, it's better to keep it here, as it's a part of the command logic
// and might be super tricky to implement in multi-root documents.
//
// Keep in mind that the highlighted offset is indexed from 1, as it's displayed to the user. It's why we subtract 1 here.
//
// More info: https://github.com/ckeditor/ckeditor5/issues/16648
const oldHighlightOffset = Math.max(this._state.highlightedOffset - 1, 0);
this._replace(replacementText, result);
// Let's revert the highlight offset to the previous value.
if (this._state.results.length) {
// Highlight offset operates on sorted array, so we need to sort the results first.
// It's not guaranteed that items in state results are sorted, usually they are, but it's not guaranteed when
// the result is removed from the paragraph with other highlighted results.
const sortedResults = sortSearchResultsByMarkerPositions(this.editor.model, [
...this._state.results
]);
// Just make sure that we don't overflow the results array, so use modulo.
this._state.highlightedResult = sortedResults[oldHighlightOffset % sortedResults.length];
}
}
}
/**
* The replace all command. It is used by the {@link module:find-and-replace/findandreplace~FindAndReplace find and replace feature}.
*/ class ReplaceAllCommand extends ReplaceCommandBase {
/**
* Replaces all the occurrences of `textToReplace` with a given `newText` string.
*
* ```ts
* replaceAllCommand.execute( 'replaceAll', 'new text replacement', 'text to replace' );
* ```
*
* Alternatively you can call it from editor instance:
*
* ```ts
* editor.execute( 'replaceAll', 'new text', 'old text' );
* ```
*
* @param newText Text that will be inserted to the editor for each match.
* @param textToReplace Text to be replaced or a collection of matches
* as returned by the find command.
*
* @fires module:core/command~Command#event:execute
*/ 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)=>findAndReplaceUtils.updateFindResultFromRange(model.createRangeIn(model.document.getRoot(rootName)), model, findAndReplaceUtils.findByTextCallback(textToReplace, this._state), currentResults), null);
if (results.length) {
// Wrapped in single change will batch it into one transaction.
model.change(()=>{
[
...results
].forEach((searchResult)=>{
// Just reuse logic from the replace command to replace a single match.
this._replace(newText, searchResult);
});
});
}
}
}
/**
* The find next command. Moves the highlight to the next search result.
*
* It is used by the {@link module:find-and-replace/findandreplace~FindAndReplace find and replace feature}.
*/ class FindNextCommand extends Command {
/**
* The find and replace state object used for command operations.
*/ _state;
/**
* Creates a new `FindNextCommand` instance.
*
* @param editor The editor on which this command will be used.
* @param state An object to hold plugin state.
*/ constructor(editor, state){
super(editor);
// It does not affect data so should be enabled in read-only mode.
this.affectsData = false;
this._state = state;
this.isEnabled = false;
this.listenTo(this._state.results, 'change', ()=>{
this.isEnabled = this._state.results.length > 1;
});
}
/**
* @inheritDoc
*/ refresh() {
this.isEnabled = this._state.results.length > 1;
}
/**
* @inheritDoc
*/ execute() {
const results = this._state.results;
const currentIndex = results.getIndex(this._state.highlightedResult);
const nextIndex = currentIndex + 1 >= results.length ? 0 : currentIndex + 1;
this._state.highlightedResult = this._state.results.get(nextIndex);
}
}
/**
* The find previous command. Moves the highlight to the previous search result.
*
* It is used by the {@link module:find-and-replace/findandreplace~FindAndReplace find and replace feature}.
*/ class FindPreviousCommand extends FindNextCommand {
/**
* @inheritDoc
*/ execute() {
const results = this._state.results;
const currentIndex = results.getIndex(this._state.highlightedResult);
const previousIndex = currentIndex - 1 < 0 ? this._state.results.length - 1 : currentIndex - 1;
this._state.highlightedResult = this._state.results.get(previousIndex);
}
}
/**
* A set of helpers related to find and replace.
*/ class FindAndReplaceUtils extends Plugin {
/**
* @inheritDoc
*/ static get pluginName() {
return 'FindAndReplaceUtils';
}
/**
* @inheritDoc
*/ static get isOfficialPlugin() {
return true;
}
/**
* Executes findCallback and updates search results list.
*
* @param range The model range to scan for matches.
* @param model The model.
* @param findCallback The callback that should return `true` if provided text matches the search term.
* @param 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 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();
const checkIfResultAlreadyOnList = (marker)=>results.find((markerItem)=>{
const { marker: resultsMarker } = markerItem;
const resultRange = resultsMarker.getRange();
const markerRange = marker.getRange();
return resultRange.isEqual(markerRange);
});
model.change((writer)=>{
[
...range
].forEach(({ type, item })=>{
if (type === 'elementStart') {
if (model.schema.checkChild(item, '$text')) {
let foundItems = findCallback({
item,
text: this.rangeToText(model.createRangeIn(item))
});
if (!foundItems) {
return;
}
if ('results' in foundItems) {
foundItems = foundItems.results;
}
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);
if (!checkIfResultAlreadyOnList(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 range The model range.
* @returns The text content of the provided range.
*/ rangeToText(range) {
return Array.from(range.getItems({
shallow: true
})).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`;
}