/
textwatcher.ts
243 lines (203 loc) · 6.42 KB
/
textwatcher.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
/**
* @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 typing/textwatcher
*/
import { ObservableMixin, type ObservableChangeEvent } from '@ckeditor/ckeditor5-utils';
import getLastTextLine from './utils/getlasttextline.js';
import type {
Batch,
Model,
Range,
DocumentChangeEvent,
DocumentSelectionChangeEvent
} from '@ckeditor/ckeditor5-engine';
/**
* The text watcher feature.
*
* Fires the {@link module:typing/textwatcher~TextWatcher#event:matched:data `matched:data`},
* {@link module:typing/textwatcher~TextWatcher#event:matched:selection `matched:selection`} and
* {@link module:typing/textwatcher~TextWatcher#event:unmatched `unmatched`} events on typing or selection changes.
*/
export default class TextWatcher extends ObservableMixin() {
/**
* The editor's model.
*/
public readonly model: Model;
/**
* The function used to match the text.
*
* The test callback can return 3 values:
*
* * `false` if there is no match,
* * `true` if there is a match,
* * an object if there is a match and we want to pass some additional information to the {@link #event:matched:data} event.
*/
public testCallback: ( text: string ) => unknown;
/**
* Whether there is a match currently.
*/
private _hasMatch: boolean;
/**
* Flag indicating whether the `TextWatcher` instance is enabled or disabled.
* A disabled TextWatcher will not evaluate text.
*
* To disable TextWatcher:
*
* ```ts
* const watcher = new TextWatcher( editor.model, testCallback );
*
* // After this a testCallback will not be called.
* watcher.isEnabled = false;
* ```
*/
declare public isEnabled: boolean;
/**
* Creates a text watcher instance.
*
* @param testCallback See {@link module:typing/textwatcher~TextWatcher#testCallback}.
*/
constructor( model: Model, testCallback: ( text: string ) => unknown ) {
super();
this.model = model;
this.testCallback = testCallback;
this._hasMatch = false;
this.set( 'isEnabled', true );
// Toggle text watching on isEnabled state change.
this.on<ObservableChangeEvent>( 'change:isEnabled', () => {
if ( this.isEnabled ) {
this._startListening();
} else {
this.stopListening( model.document.selection );
this.stopListening( model.document );
}
} );
this._startListening();
}
/**
* Flag indicating whether there is a match currently.
*/
public get hasMatch(): boolean {
return this._hasMatch;
}
/**
* Starts listening to the editor for typing and selection events.
*/
private _startListening(): void {
const model = this.model;
const document = model.document;
this.listenTo<DocumentSelectionChangeEvent>( document.selection, 'change:range', ( evt, { directChange } ) => {
// Indirect changes (i.e. when the user types or external changes are applied) are handled in the document's change event.
if ( !directChange ) {
return;
}
// Act only on collapsed selection.
if ( !document.selection.isCollapsed ) {
if ( this.hasMatch ) {
this.fire( 'unmatched' );
this._hasMatch = false;
}
return;
}
this._evaluateTextBeforeSelection( 'selection' );
} );
this.listenTo<DocumentChangeEvent>( document, 'change:data', ( evt, batch ) => {
if ( batch.isUndo || !batch.isLocal ) {
return;
}
this._evaluateTextBeforeSelection( 'data', { batch } );
} );
}
/**
* Checks the editor content for matched text.
*
* @fires matched:data
* @fires matched:selection
* @fires unmatched
*
* @param suffix A suffix used for generating the event name.
* @param data Data object for event.
*/
private _evaluateTextBeforeSelection( suffix: 'data' | 'selection', data: { batch?: Batch } = {} ): void {
const model = this.model;
const document = model.document;
const selection = document.selection;
const rangeBeforeSelection = model.createRange( model.createPositionAt( selection.focus!.parent, 0 ), selection.focus! );
const { text, range } = getLastTextLine( rangeBeforeSelection, model );
const testResult = this.testCallback( text );
if ( !testResult && this.hasMatch ) {
this.fire<TextWatcherUnmatchedEvent>( 'unmatched' );
}
this._hasMatch = !!testResult;
if ( testResult ) {
const eventData = Object.assign( data, { text, range } );
// If the test callback returns an object with additional data, assign the data as well.
if ( typeof testResult == 'object' ) {
Object.assign( eventData, testResult );
}
this.fire<TextWatcherMatchedEvent>( `matched:${ suffix }`, eventData );
}
}
}
export type TextWatcherMatchedEvent<TCallbackResult extends Record<string, unknown> = Record<string, unknown>> = {
name: 'matched' | 'matched:data' | 'matched:selection';
args: [ {
text: string;
range: Range;
batch?: Batch;
} & TCallbackResult ];
};
/**
* Fired whenever the text watcher found a match for data changes.
*
* @eventName ~TextWatcher#matched:data
* @param data Event data.
* @param data.testResult The additional data returned from the {@link module:typing/textwatcher~TextWatcher#testCallback}.
*/
export type TextWatcherMatchedDataEvent<TCallbackResult extends Record<string, unknown>> = {
name: 'matched:data';
args: [ data: TextWatcherMatchedDataEventData & TCallbackResult ];
};
export interface TextWatcherMatchedDataEventData {
/**
* The full text before selection to which the regexp was applied.
*/
text: string;
/**
* The range representing the position of the `data.text`.
*/
range: Range;
batch: Batch;
}
/**
* Fired whenever the text watcher found a match for selection changes.
*
* @eventName ~TextWatcher#matched:selection
* @param data Event data.
* @param data.testResult The additional data returned from the {@link module:typing/textwatcher~TextWatcher#testCallback}.
*/
export type TextWatcherMatchedSelectionEvent<TCallbackResult extends Record<string, unknown>> = {
name: 'matched:selection';
args: [ data: TextWatcherMatchedSelectionEventData & TCallbackResult ];
};
export interface TextWatcherMatchedSelectionEventData {
/**
* The full text before selection.
*/
text: string;
/**
* The range representing the position of the `data.text`.
*/
range: Range;
}
/**
* Fired whenever the text does not match anymore. Fired only when the text watcher found a match.
*
* @eventName ~TextWatcher#unmatched
*/
export type TextWatcherUnmatchedEvent = {
name: 'unmatched';
args: [];
};