@ckeditor/ckeditor5-find-and-replace
Version:
Find and replace feature for CKEditor 5.
278 lines (277 loc) • 11.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/findandreplaceui
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { IconFindReplace } from 'ckeditor5/src/icons.js';
import { ButtonView, MenuBarMenuListItemButtonView, Dialog, DialogViewPosition, createDropdown, DropdownView, FormHeaderView, CssTransitionDisablerMixin } from 'ckeditor5/src/ui.js';
import FindAndReplaceFormView from './ui/findandreplaceformview.js';
/**
* 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.
*/
export default 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();
}
}