@ckeditor/ckeditor5-find-and-replace
Version:
Find and replace feature for CKEditor 5.
543 lines (542 loc) • 21.2 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
*/
/**
* @module find-and-replace/ui/findandreplaceformview
*/
import { View, ButtonView, LabeledFieldView, FocusCycler, createLabeledInputText, submitHandler, ViewCollection, SwitchButtonView, CollapsibleView } from 'ckeditor5/src/ui.js';
import { FocusTracker, KeystrokeHandler, Rect, isVisible } from 'ckeditor5/src/utils.js';
// See: #8833.
// eslint-disable-next-line ckeditor5-rules/ckeditor-imports
import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css';
import '../../theme/findandreplaceform.css';
import { IconPreviousArrow } from 'ckeditor5/src/icons.js';
/**
* The find and replace form view class.
*
* See {@link module:find-and-replace/ui/findandreplaceformview~FindAndReplaceFormView}.
*/
export default 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;
}
}