-
Notifications
You must be signed in to change notification settings - Fork 3.6k
/
findandreplaceui.ts
348 lines (296 loc) · 10.3 KB
/
findandreplaceui.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
/**
* @license Copyright (c) 2003-2024, 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/findandreplaceui
*/
import { type Editor, Plugin } from 'ckeditor5/src/core.js';
import {
ButtonView,
MenuBarMenuListItemButtonView,
Dialog,
DialogViewPosition,
createDropdown,
DropdownView,
FormHeaderView,
CssTransitionDisablerMixin,
type ViewWithCssTransitionDisabler
} from 'ckeditor5/src/ui.js';
import FindAndReplaceFormView from './ui/findandreplaceformview.js';
import loupeIcon from '../theme/icons/find-replace.svg';
import type FindAndReplaceEditing from './findandreplaceediting.js';
import type FindNextCommand from './findnextcommand.js';
import type FindPreviousCommand from './findpreviouscommand.js';
import type ReplaceCommand from './replacecommand.js';
import type ReplaceAllCommand from './replaceallcommand.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
*/
public static get requires() {
return [ Dialog ] as const;
}
/**
* @inheritDoc
*/
public static get pluginName() {
return 'FindAndReplaceUI' as const;
}
/**
* A reference to the find and replace form view.
*/
public formView: FindAndReplaceFormView & ViewWithCssTransitionDisabler | null;
/**
* @inheritDoc
*/
constructor( editor: Editor ) {
super( editor );
editor.config.define( 'findAndReplace.uiType', 'dialog' );
this.formView = null;
}
/**
* @inheritDoc
*/
public init(): void {
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: DropdownView | ButtonView;
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.
*/
private _createDropdown(): DropdownView {
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: loupeIcon,
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.
*/
private _createDialogButtonForToolbar(): ButtonView {
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.
*/
private _createDialogButtonForMenuBar(): MenuBarMenuListItemButtonView {
const buttonView = this._createButton( MenuBarMenuListItemButtonView );
const dialogPlugin = this.editor.plugins.get( 'Dialog' );
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.
*/
private _createButton<T extends typeof ButtonView | typeof MenuBarMenuListItemButtonView>( ButtonClass: T ): InstanceType<T> {
const editor = this.editor;
const findCommand = editor.commands.get( 'find' )!;
const buttonView = new ButtonClass( editor.locale ) as InstanceType<T>;
const t = editor.locale.t;
// Button should be disabled when in source editing mode. See #10001.
buttonView.bind( 'isEnabled' ).to( findCommand );
buttonView.set( {
icon: loupeIcon,
label: t( 'Find and replace' ),
keystroke: 'CTRL+F'
} );
return buttonView;
}
/**
* Shows the find and replace dialog.
*/
private _showDialog(): void {
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 find and replace.
*
* @param formView A related form view.
*/
private _createFormView(): FindAndReplaceFormView & ViewWithCssTransitionDisabler {
const editor = this.editor;
const formView = new ( CssTransitionDisablerMixin( FindAndReplaceFormView ) )( editor.locale );
const commands = editor.commands;
const findAndReplaceEditing: 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: FindNextCommand = commands.get( 'findNext' )!;
const findPreviousCommand: FindPreviousCommand = commands.get( 'findPrevious' )!;
const replaceCommand: ReplaceCommand = commands.get( 'replace' )!;
const replaceAllCommand: 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.
*/
private _setupFormView(): void {
this.formView!.disableCssTransitions();
this.formView!.reset();
this.formView!._findInputView.fieldView.select();
this.formView!.enableCssTransitions();
}
}
/**
* Fired when the UI was reset and the search results marked in the editing root should be invalidated,
* for instance, because the user changed the searched phrase (or options) but didn't hit
* the "Find" button yet.
*
* @eventName ~FindAndReplaceUI#searchReseted
*/
export type SearchResetedEvent = {
name: 'searchReseted';
args: [];
};