@ckeditor/ckeditor5-typing 41.3.1 → 41.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1989 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
3
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4
+ */
5
+ import { Command, Plugin } from '@ckeditor/ckeditor5-core/dist/index.js';
6
+ import { env, EventInfo, count, keyCodes, isInsideSurrogatePair, isInsideCombinedSymbol, isInsideEmojiSequence, ObservableMixin } from '@ckeditor/ckeditor5-utils/dist/index.js';
7
+ import { Observer, FocusObserver, DomEventData, BubblingEventInfo, MouseObserver } from '@ckeditor/ckeditor5-engine/dist/index.js';
8
+ import { escapeRegExp } from 'lodash-es';
9
+
10
+ /**
11
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
12
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
13
+ */ /**
14
+ * Change buffer allows to group atomic changes (like characters that have been typed) into
15
+ * {@link module:engine/model/batch~Batch batches}.
16
+ *
17
+ * Batches represent single undo steps, hence changes added to one single batch are undone together.
18
+ *
19
+ * The buffer has a configurable limit of atomic changes that it can accommodate. After the limit was
20
+ * exceeded (see {@link ~ChangeBuffer#input}), a new batch is created in {@link ~ChangeBuffer#batch}.
21
+ *
22
+ * To use the change buffer you need to let it know about the number of changes that were added to the batch:
23
+ *
24
+ * ```ts
25
+ * const buffer = new ChangeBuffer( model, LIMIT );
26
+ *
27
+ * // Later on in your feature:
28
+ * buffer.batch.insert( pos, insertedCharacters );
29
+ * buffer.input( insertedCharacters.length );
30
+ * ```
31
+ */ class ChangeBuffer {
32
+ /**
33
+ * The current batch to which a feature should add its operations. Once the {@link #size}
34
+ * is reached or exceeds the {@link #limit}, the batch is set to a new instance and the size is reset.
35
+ */ get batch() {
36
+ if (!this._batch) {
37
+ this._batch = this.model.createBatch({
38
+ isTyping: true
39
+ });
40
+ }
41
+ return this._batch;
42
+ }
43
+ /**
44
+ * The number of atomic changes in the buffer. Once it exceeds the {@link #limit},
45
+ * the {@link #batch batch} is set to a new one.
46
+ */ get size() {
47
+ return this._size;
48
+ }
49
+ /**
50
+ * The input number of changes into the buffer. Once the {@link #size} is
51
+ * reached or exceeds the {@link #limit}, the batch is set to a new instance and the size is reset.
52
+ *
53
+ * @param changeCount The number of atomic changes to input.
54
+ */ input(changeCount) {
55
+ this._size += changeCount;
56
+ if (this._size >= this.limit) {
57
+ this._reset(true);
58
+ }
59
+ }
60
+ /**
61
+ * Whether the buffer is locked. A locked buffer cannot be reset unless it gets unlocked.
62
+ */ get isLocked() {
63
+ return this._isLocked;
64
+ }
65
+ /**
66
+ * Locks the buffer.
67
+ */ lock() {
68
+ this._isLocked = true;
69
+ }
70
+ /**
71
+ * Unlocks the buffer.
72
+ */ unlock() {
73
+ this._isLocked = false;
74
+ }
75
+ /**
76
+ * Destroys the buffer.
77
+ */ destroy() {
78
+ this.model.document.off('change', this._changeCallback);
79
+ this.model.document.selection.off('change:range', this._selectionChangeCallback);
80
+ this.model.document.selection.off('change:attribute', this._selectionChangeCallback);
81
+ }
82
+ /**
83
+ * Resets the change buffer.
84
+ *
85
+ * @param ignoreLock Whether internal lock {@link #isLocked} should be ignored.
86
+ */ _reset(ignoreLock = false) {
87
+ if (!this.isLocked || ignoreLock) {
88
+ this._batch = null;
89
+ this._size = 0;
90
+ }
91
+ }
92
+ /**
93
+ * Creates a new instance of the change buffer.
94
+ *
95
+ * @param limit The maximum number of atomic changes which can be contained in one batch.
96
+ */ constructor(model, limit = 20){
97
+ /**
98
+ * The current batch instance.
99
+ */ this._batch = null;
100
+ this.model = model;
101
+ this._size = 0;
102
+ this.limit = limit;
103
+ this._isLocked = false;
104
+ // The function to be called in order to notify the buffer about batches which appeared in the document.
105
+ // The callback will check whether it is a new batch and in that case the buffer will be flushed.
106
+ //
107
+ // The reason why the buffer needs to be flushed whenever a new batch appears is that the changes added afterwards
108
+ // should be added to a new batch. For instance, when the user types, then inserts an image, and then types again,
109
+ // the characters typed after inserting the image should be added to a different batch than the characters typed before.
110
+ this._changeCallback = (evt, batch)=>{
111
+ if (batch.isLocal && batch.isUndoable && batch !== this._batch) {
112
+ this._reset(true);
113
+ }
114
+ };
115
+ this._selectionChangeCallback = ()=>{
116
+ this._reset();
117
+ };
118
+ this.model.document.on('change', this._changeCallback);
119
+ this.model.document.selection.on('change:range', this._selectionChangeCallback);
120
+ this.model.document.selection.on('change:attribute', this._selectionChangeCallback);
121
+ }
122
+ }
123
+
124
+ class InsertTextCommand extends Command {
125
+ /**
126
+ * The current change buffer.
127
+ */ get buffer() {
128
+ return this._buffer;
129
+ }
130
+ /**
131
+ * @inheritDoc
132
+ */ destroy() {
133
+ super.destroy();
134
+ this._buffer.destroy();
135
+ }
136
+ /**
137
+ * Executes the input command. It replaces the content within the given range with the given text.
138
+ * Replacing is a two step process, first the content within the range is removed and then the new text is inserted
139
+ * at the beginning of the range (which after the removal is a collapsed range).
140
+ *
141
+ * @fires execute
142
+ * @param options The command options.
143
+ */ execute(options = {}) {
144
+ const model = this.editor.model;
145
+ const doc = model.document;
146
+ const text = options.text || '';
147
+ const textInsertions = text.length;
148
+ let selection = doc.selection;
149
+ if (options.selection) {
150
+ selection = options.selection;
151
+ } else if (options.range) {
152
+ selection = model.createSelection(options.range);
153
+ }
154
+ // Stop executing if selectable is in non-editable place.
155
+ if (!model.canEditAt(selection)) {
156
+ return;
157
+ }
158
+ const resultRange = options.resultRange;
159
+ model.enqueueChange(this._buffer.batch, (writer)=>{
160
+ this._buffer.lock();
161
+ // Store selection attributes before deleting old content to preserve formatting and link.
162
+ // This unifies the behavior between DocumentSelection and Selection provided as input option.
163
+ const selectionAttributes = Array.from(doc.selection.getAttributes());
164
+ model.deleteContent(selection);
165
+ if (text) {
166
+ model.insertContent(writer.createText(text, selectionAttributes), selection);
167
+ }
168
+ if (resultRange) {
169
+ writer.setSelection(resultRange);
170
+ } else if (!selection.is('documentSelection')) {
171
+ writer.setSelection(selection);
172
+ }
173
+ this._buffer.unlock();
174
+ this._buffer.input(textInsertions);
175
+ });
176
+ }
177
+ /**
178
+ * Creates an instance of the command.
179
+ *
180
+ * @param undoStepSize The maximum number of atomic changes
181
+ * which can be contained in one batch in the command buffer.
182
+ */ constructor(editor, undoStepSize){
183
+ super(editor);
184
+ this._buffer = new ChangeBuffer(editor.model, undoStepSize);
185
+ // Since this command may execute on different selectable than selection, it should be checked directly in execute block.
186
+ this._isEnabledBasedOnSelection = false;
187
+ }
188
+ }
189
+
190
+ const TYPING_INPUT_TYPES = [
191
+ // For collapsed range:
192
+ // - This one is a regular typing (all browsers, all systems).
193
+ // - This one is used by Chrome when typing accented letter – 2nd step when the user selects the accent (Mac).
194
+ // For non-collapsed range:
195
+ // - This one is used by Chrome when typing accented letter – when the selection box first appears (Mac).
196
+ // - This one is used by Safari when accepting spell check suggestions from the context menu (Mac).
197
+ 'insertText',
198
+ // This one is used by Safari when typing accented letter (Mac).
199
+ // This one is used by Safari when accepting spell check suggestions from the autocorrection pop-up (Mac).
200
+ 'insertReplacementText'
201
+ ];
202
+ class InsertTextObserver extends Observer {
203
+ /**
204
+ * @inheritDoc
205
+ */ observe() {}
206
+ /**
207
+ * @inheritDoc
208
+ */ stopObserving() {}
209
+ /**
210
+ * @inheritDoc
211
+ */ constructor(view){
212
+ super(view);
213
+ this.focusObserver = view.getObserver(FocusObserver);
214
+ // On Android composition events should immediately be applied to the model. Rendering is not disabled.
215
+ // On non-Android the model is updated only on composition end.
216
+ // On Android we can't rely on composition start/end to update model.
217
+ if (env.isAndroid) {
218
+ TYPING_INPUT_TYPES.push('insertCompositionText');
219
+ }
220
+ const viewDocument = view.document;
221
+ viewDocument.on('beforeinput', (evt, data)=>{
222
+ if (!this.isEnabled) {
223
+ return;
224
+ }
225
+ const { data: text, targetRanges, inputType, domEvent } = data;
226
+ if (!TYPING_INPUT_TYPES.includes(inputType)) {
227
+ return;
228
+ }
229
+ // Mark the latest focus change as complete (we are typing in editable after the focus
230
+ // so the selection is in the focused element).
231
+ this.focusObserver.flush();
232
+ const eventInfo = new EventInfo(viewDocument, 'insertText');
233
+ viewDocument.fire(eventInfo, new DomEventData(view, domEvent, {
234
+ text,
235
+ selection: view.createSelection(targetRanges)
236
+ }));
237
+ // Stop the beforeinput event if `delete` event was stopped.
238
+ // https://github.com/ckeditor/ckeditor5/issues/753
239
+ if (eventInfo.stop.called) {
240
+ evt.stop();
241
+ }
242
+ });
243
+ // Note: The priority must be lower than the CompositionObserver handler to call it after the renderer is unblocked.
244
+ viewDocument.on('compositionend', (evt, { data, domEvent })=>{
245
+ // On Android composition events are immediately applied to the model.
246
+ // On non-Android the model is updated only on composition end.
247
+ // On Android we can't rely on composition start/end to update model.
248
+ if (!this.isEnabled || env.isAndroid) {
249
+ return;
250
+ }
251
+ // In case of aborted composition.
252
+ if (!data) {
253
+ return;
254
+ }
255
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
256
+ // @if CK_DEBUG_TYPING // console.log( `%c[InsertTextObserver]%c Fire insertText event, text: ${ JSON.stringify( data ) }`,
257
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', ''
258
+ // @if CK_DEBUG_TYPING // );
259
+ // @if CK_DEBUG_TYPING // }
260
+ // How do we know where to insert the composed text?
261
+ // The selection observer is blocked and the view is not updated with the composition changes.
262
+ // There were three options:
263
+ // - Store the selection on `compositionstart` and use it now. This wouldn't work in RTC
264
+ // where the view would change and the stored selection might get incorrect.
265
+ // We'd need to fallback to the current view selection anyway.
266
+ // - Use the current view selection. This is a bit weird and non-intuitive because
267
+ // this isn't necessarily the selection on which the user started composing.
268
+ // We cannot even know whether it's still collapsed (there might be some weird
269
+ // editor feature that changed it in unpredictable ways for us). But it's by far
270
+ // the simplest solution and should be stable (the selection is definitely correct)
271
+ // and probably mostly predictable (features usually don't modify the selection
272
+ // unless called explicitly by the user).
273
+ // - Try to follow it from the `beforeinput` events. This would be really complex as each
274
+ // `beforeinput` would come with just the range it's changing and we'd need to calculate that.
275
+ // We decided to go with the 2nd option for its simplicity and stability.
276
+ viewDocument.fire('insertText', new DomEventData(view, domEvent, {
277
+ text: data,
278
+ selection: viewDocument.selection
279
+ }));
280
+ }, {
281
+ priority: 'lowest'
282
+ });
283
+ }
284
+ }
285
+
286
+ class Input extends Plugin {
287
+ /**
288
+ * @inheritDoc
289
+ */ static get pluginName() {
290
+ return 'Input';
291
+ }
292
+ /**
293
+ * @inheritDoc
294
+ */ init() {
295
+ const editor = this.editor;
296
+ const model = editor.model;
297
+ const view = editor.editing.view;
298
+ const modelSelection = model.document.selection;
299
+ view.addObserver(InsertTextObserver);
300
+ // TODO The above default configuration value should be defined using editor.config.define() once it's fixed.
301
+ const insertTextCommand = new InsertTextCommand(editor, editor.config.get('typing.undoStep') || 20);
302
+ // Register `insertText` command and add `input` command as an alias for backward compatibility.
303
+ editor.commands.add('insertText', insertTextCommand);
304
+ editor.commands.add('input', insertTextCommand);
305
+ this.listenTo(view.document, 'insertText', (evt, data)=>{
306
+ // Rendering is disabled while composing so prevent events that will be rendered by the engine
307
+ // and should not be applied by the browser.
308
+ if (!view.document.isComposing) {
309
+ data.preventDefault();
310
+ }
311
+ const { text, selection: viewSelection, resultRange: viewResultRange } = data;
312
+ // If view selection was specified, translate it to model selection.
313
+ const modelRanges = Array.from(viewSelection.getRanges()).map((viewRange)=>{
314
+ return editor.editing.mapper.toModelRange(viewRange);
315
+ });
316
+ let insertText = text;
317
+ // Typing in English on Android is firing composition events for the whole typed word.
318
+ // We need to check the target range text to only apply the difference.
319
+ if (env.isAndroid) {
320
+ const selectedText = Array.from(modelRanges[0].getItems()).reduce((rangeText, node)=>{
321
+ return rangeText + (node.is('$textProxy') ? node.data : '');
322
+ }, '');
323
+ if (selectedText) {
324
+ if (selectedText.length <= insertText.length) {
325
+ if (insertText.startsWith(selectedText)) {
326
+ insertText = insertText.substring(selectedText.length);
327
+ modelRanges[0].start = modelRanges[0].start.getShiftedBy(selectedText.length);
328
+ }
329
+ } else {
330
+ if (selectedText.startsWith(insertText)) {
331
+ // TODO this should be mapped as delete?
332
+ modelRanges[0].start = modelRanges[0].start.getShiftedBy(insertText.length);
333
+ insertText = '';
334
+ }
335
+ }
336
+ }
337
+ }
338
+ const insertTextCommandData = {
339
+ text: insertText,
340
+ selection: model.createSelection(modelRanges)
341
+ };
342
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
343
+ // @if CK_DEBUG_TYPING // console.log( '%c[Input]%c Execute insertText:',
344
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', '',
345
+ // @if CK_DEBUG_TYPING // insertText,
346
+ // @if CK_DEBUG_TYPING // `[${ modelRanges[ 0 ].start.path }]-[${ modelRanges[ 0 ].end.path }]`
347
+ // @if CK_DEBUG_TYPING // );
348
+ // @if CK_DEBUG_TYPING // }
349
+ if (viewResultRange) {
350
+ insertTextCommandData.resultRange = editor.editing.mapper.toModelRange(viewResultRange);
351
+ }
352
+ editor.execute('insertText', insertTextCommandData);
353
+ view.scrollToTheSelection();
354
+ });
355
+ if (env.isAndroid) {
356
+ // On Android with English keyboard, the composition starts just by putting caret
357
+ // at the word end or by selecting a table column. This is not a real composition started.
358
+ // Trigger delete content on first composition key pressed.
359
+ this.listenTo(view.document, 'keydown', (evt, data)=>{
360
+ if (modelSelection.isCollapsed || data.keyCode != 229 || !view.document.isComposing) {
361
+ return;
362
+ }
363
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
364
+ // @if CK_DEBUG_TYPING // const firstPositionPath = modelSelection.getFirstPosition()!.path;
365
+ // @if CK_DEBUG_TYPING // const lastPositionPath = modelSelection.getLastPosition()!.path;
366
+ // @if CK_DEBUG_TYPING // console.log( '%c[Input]%c KeyDown 229 -> model.deleteContent()',
367
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', '',
368
+ // @if CK_DEBUG_TYPING // `[${ firstPositionPath }]-[${ lastPositionPath }]`
369
+ // @if CK_DEBUG_TYPING // );
370
+ // @if CK_DEBUG_TYPING // }
371
+ deleteSelectionContent(model, insertTextCommand);
372
+ });
373
+ } else {
374
+ // Note: The priority must precede the CompositionObserver handler to call it before
375
+ // the renderer is blocked, because we want to render this change.
376
+ this.listenTo(view.document, 'compositionstart', ()=>{
377
+ if (modelSelection.isCollapsed) {
378
+ return;
379
+ }
380
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
381
+ // @if CK_DEBUG_TYPING // const firstPositionPath = modelSelection.getFirstPosition()!.path;
382
+ // @if CK_DEBUG_TYPING // const lastPositionPath = modelSelection.getLastPosition()!.path;
383
+ // @if CK_DEBUG_TYPING // console.log( '%c[Input]%c Composition start -> model.deleteContent()',
384
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', '',
385
+ // @if CK_DEBUG_TYPING // `[${ firstPositionPath }]-[${ lastPositionPath }]`
386
+ // @if CK_DEBUG_TYPING // );
387
+ // @if CK_DEBUG_TYPING // }
388
+ deleteSelectionContent(model, insertTextCommand);
389
+ });
390
+ }
391
+ }
392
+ }
393
+ function deleteSelectionContent(model, insertTextCommand) {
394
+ // By relying on the state of the input command we allow disabling the entire input easily
395
+ // by just disabling the input command. We could’ve used here the delete command but that
396
+ // would mean requiring the delete feature which would block loading one without the other.
397
+ // We could also check the editor.isReadOnly property, but that wouldn't allow to block
398
+ // the input without blocking other features.
399
+ if (!insertTextCommand.isEnabled) {
400
+ return;
401
+ }
402
+ const buffer = insertTextCommand.buffer;
403
+ buffer.lock();
404
+ model.enqueueChange(buffer.batch, ()=>{
405
+ model.deleteContent(model.document.selection);
406
+ });
407
+ buffer.unlock();
408
+ }
409
+
410
+ class DeleteCommand extends Command {
411
+ /**
412
+ * The current change buffer.
413
+ */ get buffer() {
414
+ return this._buffer;
415
+ }
416
+ /**
417
+ * Executes the delete command. Depending on whether the selection is collapsed or not, deletes its content
418
+ * or a piece of content in the {@link #direction defined direction}.
419
+ *
420
+ * @fires execute
421
+ * @param options The command options.
422
+ * @param options.unit See {@link module:engine/model/utils/modifyselection~modifySelection}'s options.
423
+ * @param options.sequence A number describing which subsequent delete event it is without the key being released.
424
+ * See the {@link module:engine/view/document~Document#event:delete} event data.
425
+ * @param options.selection Selection to remove. If not set, current model selection will be used.
426
+ */ execute(options = {}) {
427
+ const model = this.editor.model;
428
+ const doc = model.document;
429
+ model.enqueueChange(this._buffer.batch, (writer)=>{
430
+ this._buffer.lock();
431
+ const selection = writer.createSelection(options.selection || doc.selection);
432
+ // Don't execute command when selection is in non-editable place.
433
+ if (!model.canEditAt(selection)) {
434
+ return;
435
+ }
436
+ const sequence = options.sequence || 1;
437
+ // Do not replace the whole selected content if selection was collapsed.
438
+ // This prevents such situation:
439
+ //
440
+ // <h1></h1><p>[]</p> --> <h1>[</h1><p>]</p> --> <p></p>
441
+ // starting content --> after `modifySelection` --> after `deleteContent`.
442
+ const doNotResetEntireContent = selection.isCollapsed;
443
+ // Try to extend the selection in the specified direction.
444
+ if (selection.isCollapsed) {
445
+ model.modifySelection(selection, {
446
+ direction: this.direction,
447
+ unit: options.unit,
448
+ treatEmojiAsSingleUnit: true
449
+ });
450
+ }
451
+ // Check if deleting in an empty editor. See #61.
452
+ if (this._shouldEntireContentBeReplacedWithParagraph(sequence)) {
453
+ this._replaceEntireContentWithParagraph(writer);
454
+ return;
455
+ }
456
+ // Check if deleting in the first empty block.
457
+ // See https://github.com/ckeditor/ckeditor5/issues/8137.
458
+ if (this._shouldReplaceFirstBlockWithParagraph(selection, sequence)) {
459
+ this.editor.execute('paragraph', {
460
+ selection
461
+ });
462
+ return;
463
+ }
464
+ // If selection is still collapsed, then there's nothing to delete.
465
+ if (selection.isCollapsed) {
466
+ return;
467
+ }
468
+ let changeCount = 0;
469
+ selection.getFirstRange().getMinimalFlatRanges().forEach((range)=>{
470
+ changeCount += count(range.getWalker({
471
+ singleCharacters: true,
472
+ ignoreElementEnd: true,
473
+ shallow: true
474
+ }));
475
+ });
476
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
477
+ // @if CK_DEBUG_TYPING // console.log( '%c[DeleteCommand]%c Delete content',
478
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', '',
479
+ // @if CK_DEBUG_TYPING // `[${ selection.getFirstPosition()!.path }]-[${ selection.getLastPosition()!.path }]`, options
480
+ // @if CK_DEBUG_TYPING // );
481
+ // @if CK_DEBUG_TYPING // }
482
+ model.deleteContent(selection, {
483
+ doNotResetEntireContent,
484
+ direction: this.direction
485
+ });
486
+ this._buffer.input(changeCount);
487
+ writer.setSelection(selection);
488
+ this._buffer.unlock();
489
+ });
490
+ }
491
+ /**
492
+ * If the user keeps <kbd>Backspace</kbd> or <kbd>Delete</kbd> key pressed, the content of the current
493
+ * editable will be cleared. However, this will not yet lead to resetting the remaining block to a paragraph
494
+ * (which happens e.g. when the user does <kbd>Ctrl</kbd> + <kbd>A</kbd>, <kbd>Backspace</kbd>).
495
+ *
496
+ * But, if the user pressed the key in an empty editable for the first time,
497
+ * we want to replace the entire content with a paragraph if:
498
+ *
499
+ * * the current limit element is empty,
500
+ * * the paragraph is allowed in the limit element,
501
+ * * the limit doesn't already have a paragraph inside.
502
+ *
503
+ * See https://github.com/ckeditor/ckeditor5-typing/issues/61.
504
+ *
505
+ * @param sequence A number describing which subsequent delete event it is without the key being released.
506
+ */ _shouldEntireContentBeReplacedWithParagraph(sequence) {
507
+ // Does nothing if user pressed and held the "Backspace" or "Delete" key.
508
+ if (sequence > 1) {
509
+ return false;
510
+ }
511
+ const model = this.editor.model;
512
+ const doc = model.document;
513
+ const selection = doc.selection;
514
+ const limitElement = model.schema.getLimitElement(selection);
515
+ // If a collapsed selection contains the whole content it means that the content is empty
516
+ // (from the user perspective).
517
+ const limitElementIsEmpty = selection.isCollapsed && selection.containsEntireContent(limitElement);
518
+ if (!limitElementIsEmpty) {
519
+ return false;
520
+ }
521
+ if (!model.schema.checkChild(limitElement, 'paragraph')) {
522
+ return false;
523
+ }
524
+ const limitElementFirstChild = limitElement.getChild(0);
525
+ // Does nothing if the limit element already contains only a paragraph.
526
+ // We ignore the case when paragraph might have some inline elements (<p><inlineWidget>[]</inlineWidget></p>)
527
+ // because we don't support such cases yet and it's unclear whether inlineWidget shouldn't be a limit itself.
528
+ if (limitElementFirstChild && limitElementFirstChild.is('element', 'paragraph')) {
529
+ return false;
530
+ }
531
+ return true;
532
+ }
533
+ /**
534
+ * The entire content is replaced with the paragraph. Selection is moved inside the paragraph.
535
+ *
536
+ * @param writer The model writer.
537
+ */ _replaceEntireContentWithParagraph(writer) {
538
+ const model = this.editor.model;
539
+ const doc = model.document;
540
+ const selection = doc.selection;
541
+ const limitElement = model.schema.getLimitElement(selection);
542
+ const paragraph = writer.createElement('paragraph');
543
+ writer.remove(writer.createRangeIn(limitElement));
544
+ writer.insert(paragraph, limitElement);
545
+ writer.setSelection(paragraph, 0);
546
+ }
547
+ /**
548
+ * Checks if the selection is inside an empty element that is the first child of the limit element
549
+ * and should be replaced with a paragraph.
550
+ *
551
+ * @param selection The selection.
552
+ * @param sequence A number describing which subsequent delete event it is without the key being released.
553
+ */ _shouldReplaceFirstBlockWithParagraph(selection, sequence) {
554
+ const model = this.editor.model;
555
+ // Does nothing if user pressed and held the "Backspace" key or it was a "Delete" button.
556
+ if (sequence > 1 || this.direction != 'backward') {
557
+ return false;
558
+ }
559
+ if (!selection.isCollapsed) {
560
+ return false;
561
+ }
562
+ const position = selection.getFirstPosition();
563
+ const limitElement = model.schema.getLimitElement(position);
564
+ const limitElementFirstChild = limitElement.getChild(0);
565
+ // Only elements that are direct children of the limit element can be replaced.
566
+ // Unwrapping from a block quote should be handled in a dedicated feature.
567
+ if (position.parent != limitElementFirstChild) {
568
+ return false;
569
+ }
570
+ // A block should be replaced only if it was empty.
571
+ if (!selection.containsEntireContent(limitElementFirstChild)) {
572
+ return false;
573
+ }
574
+ // Replace with a paragraph only if it's allowed there.
575
+ if (!model.schema.checkChild(limitElement, 'paragraph')) {
576
+ return false;
577
+ }
578
+ // Does nothing if the limit element already contains only a paragraph.
579
+ if (limitElementFirstChild.name == 'paragraph') {
580
+ return false;
581
+ }
582
+ return true;
583
+ }
584
+ /**
585
+ * Creates an instance of the command.
586
+ *
587
+ * @param direction The directionality of the delete describing in what direction it
588
+ * should consume the content when the selection is collapsed.
589
+ */ constructor(editor, direction){
590
+ super(editor);
591
+ this.direction = direction;
592
+ this._buffer = new ChangeBuffer(editor.model, editor.config.get('typing.undoStep'));
593
+ // Since this command may execute on different selectable than selection, it should be checked directly in execute block.
594
+ this._isEnabledBasedOnSelection = false;
595
+ }
596
+ }
597
+
598
+ const DELETE_CHARACTER = 'character';
599
+ const DELETE_WORD = 'word';
600
+ const DELETE_CODE_POINT = 'codePoint';
601
+ const DELETE_SELECTION = 'selection';
602
+ const DELETE_BACKWARD = 'backward';
603
+ const DELETE_FORWARD = 'forward';
604
+ const DELETE_EVENT_TYPES = {
605
+ // --------------------------------------- Backward delete types -----------------------------------------------------
606
+ // This happens in Safari on Mac when some content is selected and Ctrl + K is pressed.
607
+ deleteContent: {
608
+ unit: DELETE_SELECTION,
609
+ // According to the Input Events Level 2 spec, this delete type has no direction
610
+ // but to keep things simple, let's default to backward.
611
+ direction: DELETE_BACKWARD
612
+ },
613
+ // Chrome and Safari on Mac: Backspace or Ctrl + H
614
+ deleteContentBackward: {
615
+ // This kind of deletions must be done on the code point-level instead of target range provided by the DOM beforeinput event.
616
+ // Take for instance "👨‍👩‍👧‍👧", it equals:
617
+ //
618
+ // * [ "👨", "ZERO WIDTH JOINER", "👩", "ZERO WIDTH JOINER", "👧", "ZERO WIDTH JOINER", "👧" ]
619
+ // * or simply "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F467}"
620
+ //
621
+ // The range provided by the browser would cause the entire multi-byte grapheme to disappear while the user
622
+ // intention when deleting backwards ("👨‍👩‍👧‍👧[]", then backspace) is gradual "decomposition" (first to "👨‍👩‍👧‍[]",
623
+ // then to "👨‍👩‍[]", etc.).
624
+ //
625
+ // * "👨‍👩‍👧‍👧[]" + backward delete (by code point) -> results in "👨‍👩‍👧[]", removed the last "👧" 👍
626
+ // * "👨‍👩‍👧‍👧[]" + backward delete (by character) -> results in "[]", removed the whole grapheme 👎
627
+ //
628
+ // Deleting by code-point is simply a better UX. See "deleteContentForward" to learn more.
629
+ unit: DELETE_CODE_POINT,
630
+ direction: DELETE_BACKWARD
631
+ },
632
+ // On Mac: Option + Backspace.
633
+ // On iOS: Hold the backspace for a while and the whole words will start to disappear.
634
+ deleteWordBackward: {
635
+ unit: DELETE_WORD,
636
+ direction: DELETE_BACKWARD
637
+ },
638
+ // Safari on Mac: Cmd + Backspace
639
+ deleteHardLineBackward: {
640
+ unit: DELETE_SELECTION,
641
+ direction: DELETE_BACKWARD
642
+ },
643
+ // Chrome on Mac: Cmd + Backspace.
644
+ deleteSoftLineBackward: {
645
+ unit: DELETE_SELECTION,
646
+ direction: DELETE_BACKWARD
647
+ },
648
+ // --------------------------------------- Forward delete types -----------------------------------------------------
649
+ // Chrome on Mac: Fn + Backspace or Ctrl + D
650
+ // Safari on Mac: Ctrl + K or Ctrl + D
651
+ deleteContentForward: {
652
+ // Unlike backward delete, this delete must be performed by character instead of by code point, which
653
+ // provides the best UX for working with accented letters.
654
+ // Take, for example "b̂" ("\u0062\u0302", or [ "LATIN SMALL LETTER B", "COMBINING CIRCUMFLEX ACCENT" ]):
655
+ //
656
+ // * "b̂[]" + backward delete (by code point) -> results in "b[]", removed the combining mark 👍
657
+ // * "[]b̂" + forward delete (by code point) -> results in "[]^", a bare combining mark does that not make sense when alone 👎
658
+ // * "[]b̂" + forward delete (by character) -> results in "[]", removed both "b" and the combining mark 👍
659
+ //
660
+ // See: "deleteContentBackward" to learn more.
661
+ unit: DELETE_CHARACTER,
662
+ direction: DELETE_FORWARD
663
+ },
664
+ // On Mac: Fn + Option + Backspace.
665
+ deleteWordForward: {
666
+ unit: DELETE_WORD,
667
+ direction: DELETE_FORWARD
668
+ },
669
+ // Chrome on Mac: Ctrl + K (you have to disable the Link plugin first, though, because it uses the same keystroke)
670
+ // This is weird that it does not work in Safari on Mac despite being listed in the official shortcuts listing
671
+ // on Apple's webpage.
672
+ deleteHardLineForward: {
673
+ unit: DELETE_SELECTION,
674
+ direction: DELETE_FORWARD
675
+ },
676
+ // At this moment there is no known way to trigger this event type but let's keep it for the symmetry with
677
+ // deleteSoftLineBackward.
678
+ deleteSoftLineForward: {
679
+ unit: DELETE_SELECTION,
680
+ direction: DELETE_FORWARD
681
+ }
682
+ };
683
+ class DeleteObserver extends Observer {
684
+ /**
685
+ * @inheritDoc
686
+ */ observe() {}
687
+ /**
688
+ * @inheritDoc
689
+ */ stopObserving() {}
690
+ /**
691
+ * @inheritDoc
692
+ */ constructor(view){
693
+ super(view);
694
+ const document = view.document;
695
+ // It matters how many subsequent deletions were made, e.g. when the backspace key was pressed and held
696
+ // by the user for some time. For instance, if such scenario ocurred and the heading the selection was
697
+ // anchored to was the only content of the editor, it will not be converted into a paragraph (the user
698
+ // wanted to clean it up, not remove it, it's about UX). Check out the DeleteCommand implementation to learn more.
699
+ //
700
+ // Fun fact: Safari on Mac won't fire beforeinput for backspace in an empty heading (only content).
701
+ let sequence = 0;
702
+ document.on('keydown', ()=>{
703
+ sequence++;
704
+ });
705
+ document.on('keyup', ()=>{
706
+ sequence = 0;
707
+ });
708
+ document.on('beforeinput', (evt, data)=>{
709
+ if (!this.isEnabled) {
710
+ return;
711
+ }
712
+ const { targetRanges, domEvent, inputType } = data;
713
+ const deleteEventSpec = DELETE_EVENT_TYPES[inputType];
714
+ if (!deleteEventSpec) {
715
+ return;
716
+ }
717
+ const deleteData = {
718
+ direction: deleteEventSpec.direction,
719
+ unit: deleteEventSpec.unit,
720
+ sequence
721
+ };
722
+ if (deleteData.unit == DELETE_SELECTION) {
723
+ deleteData.selectionToRemove = view.createSelection(targetRanges[0]);
724
+ }
725
+ // The default deletion unit for deleteContentBackward is a single code point
726
+ // but if the browser provides a wider target range then we should use it.
727
+ if (inputType === 'deleteContentBackward') {
728
+ // On Android, deleteContentBackward has sequence 1 by default.
729
+ if (env.isAndroid) {
730
+ deleteData.sequence = 1;
731
+ }
732
+ // The beforeInput event wants more than a single character to be removed.
733
+ if (shouldUseTargetRanges(targetRanges)) {
734
+ deleteData.unit = DELETE_SELECTION;
735
+ deleteData.selectionToRemove = view.createSelection(targetRanges);
736
+ }
737
+ }
738
+ const eventInfo = new BubblingEventInfo(document, 'delete', targetRanges[0]);
739
+ document.fire(eventInfo, new DomEventData(view, domEvent, deleteData));
740
+ // Stop the beforeinput event if `delete` event was stopped.
741
+ // https://github.com/ckeditor/ckeditor5/issues/753
742
+ if (eventInfo.stop.called) {
743
+ evt.stop();
744
+ }
745
+ });
746
+ // TODO: to be removed when https://bugs.chromium.org/p/chromium/issues/detail?id=1365311 is solved.
747
+ if (env.isBlink) {
748
+ enableChromeWorkaround(this);
749
+ }
750
+ }
751
+ }
752
+ /**
753
+ * Enables workaround for the issue https://github.com/ckeditor/ckeditor5/issues/11904.
754
+ */ function enableChromeWorkaround(observer) {
755
+ const view = observer.view;
756
+ const document = view.document;
757
+ let pressedKeyCode = null;
758
+ let beforeInputReceived = false;
759
+ document.on('keydown', (evt, { keyCode })=>{
760
+ pressedKeyCode = keyCode;
761
+ beforeInputReceived = false;
762
+ });
763
+ document.on('keyup', (evt, { keyCode, domEvent })=>{
764
+ const selection = document.selection;
765
+ const shouldFireDeleteEvent = observer.isEnabled && keyCode == pressedKeyCode && isDeleteKeyCode(keyCode) && !selection.isCollapsed && !beforeInputReceived;
766
+ pressedKeyCode = null;
767
+ if (shouldFireDeleteEvent) {
768
+ const targetRange = selection.getFirstRange();
769
+ const eventInfo = new BubblingEventInfo(document, 'delete', targetRange);
770
+ const deleteData = {
771
+ unit: DELETE_SELECTION,
772
+ direction: getDeleteDirection(keyCode),
773
+ selectionToRemove: selection
774
+ };
775
+ document.fire(eventInfo, new DomEventData(view, domEvent, deleteData));
776
+ }
777
+ });
778
+ document.on('beforeinput', (evt, { inputType })=>{
779
+ const deleteEventSpec = DELETE_EVENT_TYPES[inputType];
780
+ const isMatchingBeforeInput = isDeleteKeyCode(pressedKeyCode) && deleteEventSpec && deleteEventSpec.direction == getDeleteDirection(pressedKeyCode);
781
+ if (isMatchingBeforeInput) {
782
+ beforeInputReceived = true;
783
+ }
784
+ }, {
785
+ priority: 'high'
786
+ });
787
+ document.on('beforeinput', (evt, { inputType, data })=>{
788
+ const shouldIgnoreBeforeInput = pressedKeyCode == keyCodes.delete && inputType == 'insertText' && data == '\x7f'; // Delete character :P
789
+ if (shouldIgnoreBeforeInput) {
790
+ evt.stop();
791
+ }
792
+ }, {
793
+ priority: 'high'
794
+ });
795
+ function isDeleteKeyCode(keyCode) {
796
+ return keyCode == keyCodes.backspace || keyCode == keyCodes.delete;
797
+ }
798
+ function getDeleteDirection(keyCode) {
799
+ return keyCode == keyCodes.backspace ? DELETE_BACKWARD : DELETE_FORWARD;
800
+ }
801
+ }
802
+ /**
803
+ * Verifies whether the given target ranges cover more than a single character and should be used instead of a single code-point deletion.
804
+ */ function shouldUseTargetRanges(targetRanges) {
805
+ // The collapsed target range could happen for example while deleting inside an inline filler
806
+ // (it's mapped to collapsed position before an inline filler).
807
+ if (targetRanges.length != 1 || targetRanges[0].isCollapsed) {
808
+ return false;
809
+ }
810
+ const walker = targetRanges[0].getWalker({
811
+ direction: 'backward',
812
+ singleCharacters: true,
813
+ ignoreElementEnd: true
814
+ });
815
+ let count = 0;
816
+ for (const { nextPosition, item } of walker){
817
+ if (nextPosition.parent.is('$text')) {
818
+ const data = nextPosition.parent.data;
819
+ const offset = nextPosition.offset;
820
+ // Count combined symbols and emoji sequences as a single character.
821
+ if (isInsideSurrogatePair(data, offset) || isInsideCombinedSymbol(data, offset) || isInsideEmojiSequence(data, offset)) {
822
+ continue;
823
+ }
824
+ count++;
825
+ } else if (item.is('containerElement') || item.is('emptyElement')) {
826
+ count++;
827
+ }
828
+ if (count > 1) {
829
+ return true;
830
+ }
831
+ }
832
+ return false;
833
+ }
834
+
835
+ class Delete extends Plugin {
836
+ /**
837
+ * @inheritDoc
838
+ */ static get pluginName() {
839
+ return 'Delete';
840
+ }
841
+ /**
842
+ * @inheritDoc
843
+ */ init() {
844
+ const editor = this.editor;
845
+ const view = editor.editing.view;
846
+ const viewDocument = view.document;
847
+ const modelDocument = editor.model.document;
848
+ view.addObserver(DeleteObserver);
849
+ this._undoOnBackspace = false;
850
+ const deleteForwardCommand = new DeleteCommand(editor, 'forward');
851
+ // Register `deleteForward` command and add `forwardDelete` command as an alias for backward compatibility.
852
+ editor.commands.add('deleteForward', deleteForwardCommand);
853
+ editor.commands.add('forwardDelete', deleteForwardCommand);
854
+ editor.commands.add('delete', new DeleteCommand(editor, 'backward'));
855
+ this.listenTo(viewDocument, 'delete', (evt, data)=>{
856
+ // When not in composition, we handle the action, so prevent the default one.
857
+ // When in composition, it's the browser who modify the DOM (renderer is disabled).
858
+ if (!viewDocument.isComposing) {
859
+ data.preventDefault();
860
+ }
861
+ const { direction, sequence, selectionToRemove, unit } = data;
862
+ const commandName = direction === 'forward' ? 'deleteForward' : 'delete';
863
+ const commandData = {
864
+ sequence
865
+ };
866
+ if (unit == 'selection') {
867
+ const modelRanges = Array.from(selectionToRemove.getRanges()).map((viewRange)=>{
868
+ return editor.editing.mapper.toModelRange(viewRange);
869
+ });
870
+ commandData.selection = editor.model.createSelection(modelRanges);
871
+ } else {
872
+ commandData.unit = unit;
873
+ }
874
+ editor.execute(commandName, commandData);
875
+ view.scrollToTheSelection();
876
+ }, {
877
+ priority: 'low'
878
+ });
879
+ if (this.editor.plugins.has('UndoEditing')) {
880
+ this.listenTo(viewDocument, 'delete', (evt, data)=>{
881
+ if (this._undoOnBackspace && data.direction == 'backward' && data.sequence == 1 && data.unit == 'codePoint') {
882
+ this._undoOnBackspace = false;
883
+ editor.execute('undo');
884
+ data.preventDefault();
885
+ evt.stop();
886
+ }
887
+ }, {
888
+ context: '$capture'
889
+ });
890
+ this.listenTo(modelDocument, 'change', ()=>{
891
+ this._undoOnBackspace = false;
892
+ });
893
+ }
894
+ }
895
+ /**
896
+ * If the next user action after calling this method is pressing backspace, it would undo the last change.
897
+ *
898
+ * Requires {@link module:undo/undoediting~UndoEditing} plugin. If not loaded, does nothing.
899
+ */ requestUndoOnBackspace() {
900
+ if (this.editor.plugins.has('UndoEditing')) {
901
+ this._undoOnBackspace = true;
902
+ }
903
+ }
904
+ }
905
+
906
+ class Typing extends Plugin {
907
+ static get requires() {
908
+ return [
909
+ Input,
910
+ Delete
911
+ ];
912
+ }
913
+ /**
914
+ * @inheritDoc
915
+ */ static get pluginName() {
916
+ return 'Typing';
917
+ }
918
+ }
919
+
920
+ /**
921
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
922
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
923
+ */ /**
924
+ * Returns the last text line from the given range.
925
+ *
926
+ * "The last text line" is understood as text (from one or more text nodes) which is limited either by a parent block
927
+ * or by inline elements (e.g. `<softBreak>`).
928
+ *
929
+ * ```ts
930
+ * const rangeToCheck = model.createRange(
931
+ * model.createPositionAt( paragraph, 0 ),
932
+ * model.createPositionAt( paragraph, 'end' )
933
+ * );
934
+ *
935
+ * const { text, range } = getLastTextLine( rangeToCheck, model );
936
+ * ```
937
+ *
938
+ * For model below, the returned `text` will be "Foo bar baz" and `range` will be set on whole `<paragraph>` content:
939
+ *
940
+ * ```xml
941
+ * <paragraph>Foo bar baz<paragraph>
942
+ * ```
943
+ *
944
+ * However, in below case, `text` will be set to "baz" and `range` will be set only on "baz".
945
+ *
946
+ * ```xml
947
+ * <paragraph>Foo<softBreak></softBreak>bar<softBreak></softBreak>baz<paragraph>
948
+ * ```
949
+ */ function getLastTextLine(range, model) {
950
+ let start = range.start;
951
+ const text = Array.from(range.getWalker({
952
+ ignoreElementEnd: false
953
+ })).reduce((rangeText, { item })=>{
954
+ // Trim text to a last occurrence of an inline element and update range start.
955
+ if (!(item.is('$text') || item.is('$textProxy'))) {
956
+ start = model.createPositionAfter(item);
957
+ return '';
958
+ }
959
+ return rangeText + item.data;
960
+ }, '');
961
+ return {
962
+ text,
963
+ range: model.createRange(start, range.end)
964
+ };
965
+ }
966
+
967
+ class TextWatcher extends ObservableMixin() {
968
+ /**
969
+ * Flag indicating whether there is a match currently.
970
+ */ get hasMatch() {
971
+ return this._hasMatch;
972
+ }
973
+ /**
974
+ * Starts listening to the editor for typing and selection events.
975
+ */ _startListening() {
976
+ const model = this.model;
977
+ const document = model.document;
978
+ this.listenTo(document.selection, 'change:range', (evt, { directChange })=>{
979
+ // Indirect changes (i.e. when the user types or external changes are applied) are handled in the document's change event.
980
+ if (!directChange) {
981
+ return;
982
+ }
983
+ // Act only on collapsed selection.
984
+ if (!document.selection.isCollapsed) {
985
+ if (this.hasMatch) {
986
+ this.fire('unmatched');
987
+ this._hasMatch = false;
988
+ }
989
+ return;
990
+ }
991
+ this._evaluateTextBeforeSelection('selection');
992
+ });
993
+ this.listenTo(document, 'change:data', (evt, batch)=>{
994
+ if (batch.isUndo || !batch.isLocal) {
995
+ return;
996
+ }
997
+ this._evaluateTextBeforeSelection('data', {
998
+ batch
999
+ });
1000
+ });
1001
+ }
1002
+ /**
1003
+ * Checks the editor content for matched text.
1004
+ *
1005
+ * @fires matched:data
1006
+ * @fires matched:selection
1007
+ * @fires unmatched
1008
+ *
1009
+ * @param suffix A suffix used for generating the event name.
1010
+ * @param data Data object for event.
1011
+ */ _evaluateTextBeforeSelection(suffix, data = {}) {
1012
+ const model = this.model;
1013
+ const document = model.document;
1014
+ const selection = document.selection;
1015
+ const rangeBeforeSelection = model.createRange(model.createPositionAt(selection.focus.parent, 0), selection.focus);
1016
+ const { text, range } = getLastTextLine(rangeBeforeSelection, model);
1017
+ const testResult = this.testCallback(text);
1018
+ if (!testResult && this.hasMatch) {
1019
+ this.fire('unmatched');
1020
+ }
1021
+ this._hasMatch = !!testResult;
1022
+ if (testResult) {
1023
+ const eventData = Object.assign(data, {
1024
+ text,
1025
+ range
1026
+ });
1027
+ // If the test callback returns an object with additional data, assign the data as well.
1028
+ if (typeof testResult == 'object') {
1029
+ Object.assign(eventData, testResult);
1030
+ }
1031
+ this.fire(`matched:${suffix}`, eventData);
1032
+ }
1033
+ }
1034
+ /**
1035
+ * Creates a text watcher instance.
1036
+ *
1037
+ * @param testCallback See {@link module:typing/textwatcher~TextWatcher#testCallback}.
1038
+ */ constructor(model, testCallback){
1039
+ super();
1040
+ this.model = model;
1041
+ this.testCallback = testCallback;
1042
+ this._hasMatch = false;
1043
+ this.set('isEnabled', true);
1044
+ // Toggle text watching on isEnabled state change.
1045
+ this.on('change:isEnabled', ()=>{
1046
+ if (this.isEnabled) {
1047
+ this._startListening();
1048
+ } else {
1049
+ this.stopListening(model.document.selection);
1050
+ this.stopListening(model.document);
1051
+ }
1052
+ });
1053
+ this._startListening();
1054
+ }
1055
+ }
1056
+
1057
+ class TwoStepCaretMovement extends Plugin {
1058
+ /**
1059
+ * @inheritDoc
1060
+ */ static get pluginName() {
1061
+ return 'TwoStepCaretMovement';
1062
+ }
1063
+ /**
1064
+ * @inheritDoc
1065
+ */ init() {
1066
+ const editor = this.editor;
1067
+ const model = editor.model;
1068
+ const view = editor.editing.view;
1069
+ const locale = editor.locale;
1070
+ const modelSelection = model.document.selection;
1071
+ // Listen to keyboard events and handle the caret movement according to the 2-step caret logic.
1072
+ this.listenTo(view.document, 'arrowKey', (evt, data)=>{
1073
+ // This implementation works only for collapsed selection.
1074
+ if (!modelSelection.isCollapsed) {
1075
+ return;
1076
+ }
1077
+ // When user tries to expand the selection or jump over the whole word or to the beginning/end then
1078
+ // two-steps movement is not necessary.
1079
+ if (data.shiftKey || data.altKey || data.ctrlKey) {
1080
+ return;
1081
+ }
1082
+ const arrowRightPressed = data.keyCode == keyCodes.arrowright;
1083
+ const arrowLeftPressed = data.keyCode == keyCodes.arrowleft;
1084
+ // When neither left or right arrow has been pressed then do noting.
1085
+ if (!arrowRightPressed && !arrowLeftPressed) {
1086
+ return;
1087
+ }
1088
+ const contentDirection = locale.contentLanguageDirection;
1089
+ let isMovementHandled = false;
1090
+ if (contentDirection === 'ltr' && arrowRightPressed || contentDirection === 'rtl' && arrowLeftPressed) {
1091
+ isMovementHandled = this._handleForwardMovement(data);
1092
+ } else {
1093
+ isMovementHandled = this._handleBackwardMovement(data);
1094
+ }
1095
+ // Stop the keydown event if the two-step caret movement handled it. Avoid collisions
1096
+ // with other features which may also take over the caret movement (e.g. Widget).
1097
+ if (isMovementHandled === true) {
1098
+ evt.stop();
1099
+ }
1100
+ }, {
1101
+ context: '$text',
1102
+ priority: 'highest'
1103
+ });
1104
+ // The automatic gravity restoration logic.
1105
+ this.listenTo(modelSelection, 'change:range', (evt, data)=>{
1106
+ // Skipping the automatic restoration is needed if the selection should change
1107
+ // but the gravity must remain overridden afterwards. See the #handleBackwardMovement
1108
+ // to learn more.
1109
+ if (this._isNextGravityRestorationSkipped) {
1110
+ this._isNextGravityRestorationSkipped = false;
1111
+ return;
1112
+ }
1113
+ // Skip automatic restore when the gravity is not overridden — simply, there's nothing to restore
1114
+ // at this moment.
1115
+ if (!this._isGravityOverridden) {
1116
+ return;
1117
+ }
1118
+ // Skip automatic restore when the change is indirect AND the selection is at the attribute boundary.
1119
+ // It means that e.g. if the change was external (collaboration) and the user had their
1120
+ // selection around the link, its gravity should remain intact in this change:range event.
1121
+ if (!data.directChange && isBetweenDifferentAttributes(modelSelection.getFirstPosition(), this.attributes)) {
1122
+ return;
1123
+ }
1124
+ this._restoreGravity();
1125
+ });
1126
+ // Handle a click at the beginning/end of a two-step element.
1127
+ this._enableClickingAfterNode();
1128
+ // Change the attributes of the selection in certain situations after the two-step node was inserted into the document.
1129
+ this._enableInsertContentSelectionAttributesFixer();
1130
+ // Handle removing the content after the two-step node.
1131
+ this._handleDeleteContentAfterNode();
1132
+ }
1133
+ /**
1134
+ * Registers a given attribute for the two-step caret movement.
1135
+ *
1136
+ * @param attribute Name of the attribute to handle.
1137
+ */ registerAttribute(attribute) {
1138
+ this.attributes.add(attribute);
1139
+ }
1140
+ /**
1141
+ * Updates the document selection and the view according to the two–step caret movement state
1142
+ * when moving **forwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
1143
+ *
1144
+ * @param data Data of the key press.
1145
+ * @returns `true` when the handler prevented caret movement.
1146
+ */ _handleForwardMovement(data) {
1147
+ const attributes = this.attributes;
1148
+ const model = this.editor.model;
1149
+ const selection = model.document.selection;
1150
+ const position = selection.getFirstPosition();
1151
+ // DON'T ENGAGE 2-SCM if gravity is already overridden. It means that we just entered
1152
+ //
1153
+ // <paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
1154
+ //
1155
+ // or left the attribute
1156
+ //
1157
+ // <paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
1158
+ //
1159
+ // and the gravity will be restored automatically.
1160
+ if (this._isGravityOverridden) {
1161
+ return false;
1162
+ }
1163
+ // DON'T ENGAGE 2-SCM when the selection is at the beginning of the block AND already has the
1164
+ // attribute:
1165
+ // * when the selection was initially set there using the mouse,
1166
+ // * when the editor has just started
1167
+ //
1168
+ // <paragraph><$text attribute>{}bar</$text>baz</paragraph>
1169
+ //
1170
+ if (position.isAtStart && hasAnyAttribute(selection, attributes)) {
1171
+ return false;
1172
+ }
1173
+ // ENGAGE 2-SCM When at least one of the observed attributes changes its value (incl. starts, ends).
1174
+ //
1175
+ // <paragraph>foo<$text attribute>bar{}</$text>baz</paragraph>
1176
+ // <paragraph>foo<$text attribute>bar{}</$text><$text otherAttribute>baz</$text></paragraph>
1177
+ // <paragraph>foo<$text attribute=1>bar{}</$text><$text attribute=2>baz</$text></paragraph>
1178
+ // <paragraph>foo{}<$text attribute>bar</$text>baz</paragraph>
1179
+ //
1180
+ if (isBetweenDifferentAttributes(position, attributes)) {
1181
+ preventCaretMovement(data);
1182
+ // CLEAR 2-SCM attributes if we are at the end of one 2-SCM and before
1183
+ // the next one with a different value of the same attribute.
1184
+ //
1185
+ // <paragraph>foo<$text attribute=1>bar{}</$text><$text attribute=2>bar</$text>baz</paragraph>
1186
+ //
1187
+ if (hasAnyAttribute(selection, attributes) && isBetweenDifferentAttributes(position, attributes, true)) {
1188
+ clearSelectionAttributes(model, attributes);
1189
+ } else {
1190
+ this._overrideGravity();
1191
+ }
1192
+ return true;
1193
+ }
1194
+ return false;
1195
+ }
1196
+ /**
1197
+ * Updates the document selection and the view according to the two–step caret movement state
1198
+ * when moving **backwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
1199
+ *
1200
+ * @param data Data of the key press.
1201
+ * @returns `true` when the handler prevented caret movement
1202
+ */ _handleBackwardMovement(data) {
1203
+ const attributes = this.attributes;
1204
+ const model = this.editor.model;
1205
+ const selection = model.document.selection;
1206
+ const position = selection.getFirstPosition();
1207
+ // When the gravity is already overridden (by this plugin), it means we are on the two-step position.
1208
+ // Prevent the movement, restore the gravity and update selection attributes.
1209
+ //
1210
+ // <paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>{}baz</$text></paragraph>
1211
+ // <paragraph>foo<$text attribute>bar</$text><$text otherAttribute>{}baz</$text></paragraph>
1212
+ // <paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
1213
+ // <paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
1214
+ //
1215
+ if (this._isGravityOverridden) {
1216
+ preventCaretMovement(data);
1217
+ this._restoreGravity();
1218
+ // CLEAR 2-SCM attributes if we are at the end of one 2-SCM and before
1219
+ // the next one with a different value of the same attribute.
1220
+ //
1221
+ // <paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>{}bar</$text>baz</paragraph>
1222
+ //
1223
+ if (isBetweenDifferentAttributes(position, attributes, true)) {
1224
+ clearSelectionAttributes(model, attributes);
1225
+ } else {
1226
+ setSelectionAttributesFromTheNodeBefore(model, attributes, position);
1227
+ }
1228
+ return true;
1229
+ } else {
1230
+ // REMOVE SELECTION ATTRIBUTE when restoring gravity towards a non-existent content at the
1231
+ // beginning of the block.
1232
+ //
1233
+ // <paragraph>{}<$text attribute>bar</$text></paragraph>
1234
+ //
1235
+ if (position.isAtStart) {
1236
+ if (hasAnyAttribute(selection, attributes)) {
1237
+ preventCaretMovement(data);
1238
+ setSelectionAttributesFromTheNodeBefore(model, attributes, position);
1239
+ return true;
1240
+ }
1241
+ return false;
1242
+ }
1243
+ // SET 2-SCM attributes if we are between nodes with the same attribute but with different values.
1244
+ //
1245
+ // <paragraph>foo<$text attribute=1>bar</$text>[]<$text attribute=2>bar</$text>baz</paragraph>
1246
+ //
1247
+ if (!hasAnyAttribute(selection, attributes) && isBetweenDifferentAttributes(position, attributes, true)) {
1248
+ preventCaretMovement(data);
1249
+ setSelectionAttributesFromTheNodeBefore(model, attributes, position);
1250
+ return true;
1251
+ }
1252
+ // When we are moving from natural gravity, to the position of the 2SCM, we need to override the gravity,
1253
+ // and make sure it won't be restored. Unless it's at the end of the block and an observed attribute.
1254
+ // We need to check if the caret is a one position before the attribute boundary:
1255
+ //
1256
+ // <paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>b{}az</$text></paragraph>
1257
+ // <paragraph>foo<$text attribute>bar</$text><$text otherAttribute>b{}az</$text></paragraph>
1258
+ // <paragraph>foo<$text attribute>b{}ar</$text>baz</paragraph>
1259
+ // <paragraph>foo<$text attribute>bar</$text>b{}az</paragraph>
1260
+ //
1261
+ if (isStepAfterAnyAttributeBoundary(position, attributes)) {
1262
+ // ENGAGE 2-SCM if the selection has no attribute. This may happen when the user
1263
+ // left the attribute using a FORWARD 2-SCM.
1264
+ //
1265
+ // <paragraph><$text attribute>bar</$text>{}</paragraph>
1266
+ //
1267
+ if (position.isAtEnd && !hasAnyAttribute(selection, attributes) && isBetweenDifferentAttributes(position, attributes)) {
1268
+ preventCaretMovement(data);
1269
+ setSelectionAttributesFromTheNodeBefore(model, attributes, position);
1270
+ return true;
1271
+ }
1272
+ // Skip the automatic gravity restore upon the next selection#change:range event.
1273
+ // If not skipped, it would automatically restore the gravity, which should remain
1274
+ // overridden.
1275
+ this._isNextGravityRestorationSkipped = true;
1276
+ this._overrideGravity();
1277
+ // Don't return "true" here because we didn't call _preventCaretMovement.
1278
+ // Returning here will destabilize the filler logic, which also listens to
1279
+ // keydown (and the event would be stopped).
1280
+ return false;
1281
+ }
1282
+ }
1283
+ return false;
1284
+ }
1285
+ /**
1286
+ * Starts listening to {@link module:engine/view/document~Document#event:mousedown} and
1287
+ * {@link module:engine/view/document~Document#event:selectionChange} and puts the selection before/after a 2-step node
1288
+ * if clicked at the beginning/ending of the 2-step node.
1289
+ *
1290
+ * The purpose of this action is to allow typing around the 2-step node directly after a click.
1291
+ *
1292
+ * See https://github.com/ckeditor/ckeditor5/issues/1016.
1293
+ */ _enableClickingAfterNode() {
1294
+ const editor = this.editor;
1295
+ const model = editor.model;
1296
+ const selection = model.document.selection;
1297
+ const document = editor.editing.view.document;
1298
+ editor.editing.view.addObserver(MouseObserver);
1299
+ let clicked = false;
1300
+ // Detect the click.
1301
+ this.listenTo(document, 'mousedown', ()=>{
1302
+ clicked = true;
1303
+ });
1304
+ // When the selection has changed...
1305
+ this.listenTo(document, 'selectionChange', ()=>{
1306
+ const attributes = this.attributes;
1307
+ if (!clicked) {
1308
+ return;
1309
+ }
1310
+ // ...and it was caused by the click...
1311
+ clicked = false;
1312
+ // ...and no text is selected...
1313
+ if (!selection.isCollapsed) {
1314
+ return;
1315
+ }
1316
+ // ...and clicked text is the 2-step node...
1317
+ if (!hasAnyAttribute(selection, attributes)) {
1318
+ return;
1319
+ }
1320
+ const position = selection.getFirstPosition();
1321
+ if (!isBetweenDifferentAttributes(position, attributes)) {
1322
+ return;
1323
+ }
1324
+ // The selection at the start of a block would use surrounding attributes
1325
+ // from text after the selection so just clear 2-SCM attributes.
1326
+ //
1327
+ // Also, clear attributes for selection between same attribute with different values.
1328
+ if (position.isAtStart || isBetweenDifferentAttributes(position, attributes, true)) {
1329
+ clearSelectionAttributes(model, attributes);
1330
+ } else if (!this._isGravityOverridden) {
1331
+ this._overrideGravity();
1332
+ }
1333
+ });
1334
+ }
1335
+ /**
1336
+ * Starts listening to {@link module:engine/model/model~Model#event:insertContent} and corrects the model
1337
+ * selection attributes if the selection is at the end of a two-step node after inserting the content.
1338
+ *
1339
+ * The purpose of this action is to improve the overall UX because the user is no longer "trapped" by the
1340
+ * two-step attribute of the selection, and they can type a "clean" (`linkHref`–less) text right away.
1341
+ *
1342
+ * See https://github.com/ckeditor/ckeditor5/issues/6053.
1343
+ */ _enableInsertContentSelectionAttributesFixer() {
1344
+ const editor = this.editor;
1345
+ const model = editor.model;
1346
+ const selection = model.document.selection;
1347
+ const attributes = this.attributes;
1348
+ this.listenTo(model, 'insertContent', ()=>{
1349
+ const position = selection.getFirstPosition();
1350
+ if (hasAnyAttribute(selection, attributes) && isBetweenDifferentAttributes(position, attributes)) {
1351
+ clearSelectionAttributes(model, attributes);
1352
+ }
1353
+ }, {
1354
+ priority: 'low'
1355
+ });
1356
+ }
1357
+ /**
1358
+ * Starts listening to {@link module:engine/model/model~Model#deleteContent} and checks whether
1359
+ * removing a content right after the tow-step attribute.
1360
+ *
1361
+ * If so, the selection should not preserve the two-step attribute. However, if
1362
+ * the {@link module:typing/twostepcaretmovement~TwoStepCaretMovement} plugin is active and
1363
+ * the selection has the two-step attribute due to overridden gravity (at the end), the two-step attribute should stay untouched.
1364
+ *
1365
+ * The purpose of this action is to allow removing the link text and keep the selection outside the link.
1366
+ *
1367
+ * See https://github.com/ckeditor/ckeditor5/issues/7521.
1368
+ */ _handleDeleteContentAfterNode() {
1369
+ const editor = this.editor;
1370
+ const model = editor.model;
1371
+ const selection = model.document.selection;
1372
+ const view = editor.editing.view;
1373
+ let isBackspace = false;
1374
+ let shouldPreserveAttributes = false;
1375
+ // Detect pressing `Backspace`.
1376
+ this.listenTo(view.document, 'delete', (evt, data)=>{
1377
+ isBackspace = data.direction === 'backward';
1378
+ }, {
1379
+ priority: 'high'
1380
+ });
1381
+ // Before removing the content, check whether the selection is inside a two-step attribute.
1382
+ // If so, we want to preserve those attributes.
1383
+ this.listenTo(model, 'deleteContent', ()=>{
1384
+ if (!isBackspace) {
1385
+ return;
1386
+ }
1387
+ const position = selection.getFirstPosition();
1388
+ shouldPreserveAttributes = hasAnyAttribute(selection, this.attributes) && !isStepAfterAnyAttributeBoundary(position, this.attributes);
1389
+ }, {
1390
+ priority: 'high'
1391
+ });
1392
+ // After removing the content, check whether the current selection should preserve the `linkHref` attribute.
1393
+ this.listenTo(model, 'deleteContent', ()=>{
1394
+ if (!isBackspace) {
1395
+ return;
1396
+ }
1397
+ isBackspace = false;
1398
+ // Do not escape two-step attribute if it was inside it before content deletion.
1399
+ if (shouldPreserveAttributes) {
1400
+ return;
1401
+ }
1402
+ // Use `model.enqueueChange()` in order to execute the callback at the end of the changes process.
1403
+ editor.model.enqueueChange(()=>{
1404
+ const position = selection.getFirstPosition();
1405
+ if (hasAnyAttribute(selection, this.attributes) && isBetweenDifferentAttributes(position, this.attributes)) {
1406
+ if (position.isAtStart || isBetweenDifferentAttributes(position, this.attributes, true)) {
1407
+ clearSelectionAttributes(model, this.attributes);
1408
+ } else if (!this._isGravityOverridden) {
1409
+ this._overrideGravity();
1410
+ }
1411
+ }
1412
+ });
1413
+ }, {
1414
+ priority: 'low'
1415
+ });
1416
+ }
1417
+ /**
1418
+ * `true` when the gravity is overridden for the plugin.
1419
+ */ get _isGravityOverridden() {
1420
+ return !!this._overrideUid;
1421
+ }
1422
+ /**
1423
+ * Overrides the gravity using the {@link module:engine/model/writer~Writer model writer}
1424
+ * and stores the information about this fact in the {@link #_overrideUid}.
1425
+ *
1426
+ * A shorthand for {@link module:engine/model/writer~Writer#overrideSelectionGravity}.
1427
+ */ _overrideGravity() {
1428
+ this._overrideUid = this.editor.model.change((writer)=>{
1429
+ return writer.overrideSelectionGravity();
1430
+ });
1431
+ }
1432
+ /**
1433
+ * Restores the gravity using the {@link module:engine/model/writer~Writer model writer}.
1434
+ *
1435
+ * A shorthand for {@link module:engine/model/writer~Writer#restoreSelectionGravity}.
1436
+ */ _restoreGravity() {
1437
+ this.editor.model.change((writer)=>{
1438
+ writer.restoreSelectionGravity(this._overrideUid);
1439
+ this._overrideUid = null;
1440
+ });
1441
+ }
1442
+ /**
1443
+ * @inheritDoc
1444
+ */ constructor(editor){
1445
+ super(editor);
1446
+ /**
1447
+ * A flag indicating that the automatic gravity restoration should not happen upon the next
1448
+ * gravity restoration.
1449
+ * {@link module:engine/model/selection~Selection#event:change:range} event.
1450
+ */ this._isNextGravityRestorationSkipped = false;
1451
+ this.attributes = new Set();
1452
+ this._overrideUid = null;
1453
+ }
1454
+ }
1455
+ /**
1456
+ * Checks whether the selection has any of given attributes.
1457
+ */ function hasAnyAttribute(selection, attributes) {
1458
+ for (const observedAttribute of attributes){
1459
+ if (selection.hasAttribute(observedAttribute)) {
1460
+ return true;
1461
+ }
1462
+ }
1463
+ return false;
1464
+ }
1465
+ /**
1466
+ * Applies the given attributes to the current selection using using the
1467
+ * values from the node before the current position. Uses
1468
+ * the {@link module:engine/model/writer~Writer model writer}.
1469
+ */ function setSelectionAttributesFromTheNodeBefore(model, attributes, position) {
1470
+ const nodeBefore = position.nodeBefore;
1471
+ model.change((writer)=>{
1472
+ if (nodeBefore) {
1473
+ const attributes = [];
1474
+ const isInlineObject = model.schema.isObject(nodeBefore) && model.schema.isInline(nodeBefore);
1475
+ for (const [key, value] of nodeBefore.getAttributes()){
1476
+ if (model.schema.checkAttribute('$text', key) && (!isInlineObject || model.schema.getAttributeProperties(key).copyFromObject !== false)) {
1477
+ attributes.push([
1478
+ key,
1479
+ value
1480
+ ]);
1481
+ }
1482
+ }
1483
+ writer.setSelectionAttribute(attributes);
1484
+ } else {
1485
+ writer.removeSelectionAttribute(attributes);
1486
+ }
1487
+ });
1488
+ }
1489
+ /**
1490
+ * Removes 2-SCM attributes from the selection.
1491
+ */ function clearSelectionAttributes(model, attributes) {
1492
+ model.change((writer)=>{
1493
+ writer.removeSelectionAttribute(attributes);
1494
+ });
1495
+ }
1496
+ /**
1497
+ * Prevents the caret movement in the view by calling `preventDefault` on the event data.
1498
+ *
1499
+ * @alias data.preventDefault
1500
+ */ function preventCaretMovement(data) {
1501
+ data.preventDefault();
1502
+ }
1503
+ /**
1504
+ * Checks whether the step before `isBetweenDifferentAttributes()`.
1505
+ */ function isStepAfterAnyAttributeBoundary(position, attributes) {
1506
+ const positionBefore = position.getShiftedBy(-1);
1507
+ return isBetweenDifferentAttributes(positionBefore, attributes);
1508
+ }
1509
+ /**
1510
+ * Checks whether the given position is between different values of given attributes.
1511
+ */ function isBetweenDifferentAttributes(position, attributes, isStrict = false) {
1512
+ const { nodeBefore, nodeAfter } = position;
1513
+ for (const observedAttribute of attributes){
1514
+ const attrBefore = nodeBefore ? nodeBefore.getAttribute(observedAttribute) : undefined;
1515
+ const attrAfter = nodeAfter ? nodeAfter.getAttribute(observedAttribute) : undefined;
1516
+ if (isStrict && (attrBefore === undefined || attrAfter === undefined)) {
1517
+ continue;
1518
+ }
1519
+ if (attrAfter !== attrBefore) {
1520
+ return true;
1521
+ }
1522
+ }
1523
+ return false;
1524
+ }
1525
+
1526
+ // All named transformations.
1527
+ const TRANSFORMATIONS = {
1528
+ // Common symbols:
1529
+ copyright: {
1530
+ from: '(c)',
1531
+ to: '©'
1532
+ },
1533
+ registeredTrademark: {
1534
+ from: '(r)',
1535
+ to: '®'
1536
+ },
1537
+ trademark: {
1538
+ from: '(tm)',
1539
+ to: '™'
1540
+ },
1541
+ // Mathematical:
1542
+ oneHalf: {
1543
+ from: /(^|[^/a-z0-9])(1\/2)([^/a-z0-9])$/i,
1544
+ to: [
1545
+ null,
1546
+ '½',
1547
+ null
1548
+ ]
1549
+ },
1550
+ oneThird: {
1551
+ from: /(^|[^/a-z0-9])(1\/3)([^/a-z0-9])$/i,
1552
+ to: [
1553
+ null,
1554
+ '⅓',
1555
+ null
1556
+ ]
1557
+ },
1558
+ twoThirds: {
1559
+ from: /(^|[^/a-z0-9])(2\/3)([^/a-z0-9])$/i,
1560
+ to: [
1561
+ null,
1562
+ '⅔',
1563
+ null
1564
+ ]
1565
+ },
1566
+ oneForth: {
1567
+ from: /(^|[^/a-z0-9])(1\/4)([^/a-z0-9])$/i,
1568
+ to: [
1569
+ null,
1570
+ '¼',
1571
+ null
1572
+ ]
1573
+ },
1574
+ threeQuarters: {
1575
+ from: /(^|[^/a-z0-9])(3\/4)([^/a-z0-9])$/i,
1576
+ to: [
1577
+ null,
1578
+ '¾',
1579
+ null
1580
+ ]
1581
+ },
1582
+ lessThanOrEqual: {
1583
+ from: '<=',
1584
+ to: '≤'
1585
+ },
1586
+ greaterThanOrEqual: {
1587
+ from: '>=',
1588
+ to: '≥'
1589
+ },
1590
+ notEqual: {
1591
+ from: '!=',
1592
+ to: '≠'
1593
+ },
1594
+ arrowLeft: {
1595
+ from: '<-',
1596
+ to: '←'
1597
+ },
1598
+ arrowRight: {
1599
+ from: '->',
1600
+ to: '→'
1601
+ },
1602
+ // Typography:
1603
+ horizontalEllipsis: {
1604
+ from: '...',
1605
+ to: '…'
1606
+ },
1607
+ enDash: {
1608
+ from: /(^| )(--)( )$/,
1609
+ to: [
1610
+ null,
1611
+ '–',
1612
+ null
1613
+ ]
1614
+ },
1615
+ emDash: {
1616
+ from: /(^| )(---)( )$/,
1617
+ to: [
1618
+ null,
1619
+ '—',
1620
+ null
1621
+ ]
1622
+ },
1623
+ // Quotations:
1624
+ // English, US
1625
+ quotesPrimary: {
1626
+ from: buildQuotesRegExp('"'),
1627
+ to: [
1628
+ null,
1629
+ '“',
1630
+ null,
1631
+ '”'
1632
+ ]
1633
+ },
1634
+ quotesSecondary: {
1635
+ from: buildQuotesRegExp('\''),
1636
+ to: [
1637
+ null,
1638
+ '‘',
1639
+ null,
1640
+ '’'
1641
+ ]
1642
+ },
1643
+ // English, UK
1644
+ quotesPrimaryEnGb: {
1645
+ from: buildQuotesRegExp('\''),
1646
+ to: [
1647
+ null,
1648
+ '‘',
1649
+ null,
1650
+ '’'
1651
+ ]
1652
+ },
1653
+ quotesSecondaryEnGb: {
1654
+ from: buildQuotesRegExp('"'),
1655
+ to: [
1656
+ null,
1657
+ '“',
1658
+ null,
1659
+ '”'
1660
+ ]
1661
+ },
1662
+ // Polish
1663
+ quotesPrimaryPl: {
1664
+ from: buildQuotesRegExp('"'),
1665
+ to: [
1666
+ null,
1667
+ '„',
1668
+ null,
1669
+ '”'
1670
+ ]
1671
+ },
1672
+ quotesSecondaryPl: {
1673
+ from: buildQuotesRegExp('\''),
1674
+ to: [
1675
+ null,
1676
+ '‚',
1677
+ null,
1678
+ '’'
1679
+ ]
1680
+ }
1681
+ };
1682
+ // Transformation groups.
1683
+ const TRANSFORMATION_GROUPS = {
1684
+ symbols: [
1685
+ 'copyright',
1686
+ 'registeredTrademark',
1687
+ 'trademark'
1688
+ ],
1689
+ mathematical: [
1690
+ 'oneHalf',
1691
+ 'oneThird',
1692
+ 'twoThirds',
1693
+ 'oneForth',
1694
+ 'threeQuarters',
1695
+ 'lessThanOrEqual',
1696
+ 'greaterThanOrEqual',
1697
+ 'notEqual',
1698
+ 'arrowLeft',
1699
+ 'arrowRight'
1700
+ ],
1701
+ typography: [
1702
+ 'horizontalEllipsis',
1703
+ 'enDash',
1704
+ 'emDash'
1705
+ ],
1706
+ quotes: [
1707
+ 'quotesPrimary',
1708
+ 'quotesSecondary'
1709
+ ]
1710
+ };
1711
+ // A set of default transformations provided by the feature.
1712
+ const DEFAULT_TRANSFORMATIONS = [
1713
+ 'symbols',
1714
+ 'mathematical',
1715
+ 'typography',
1716
+ 'quotes'
1717
+ ];
1718
+ class TextTransformation extends Plugin {
1719
+ /**
1720
+ * @inheritDoc
1721
+ */ static get requires() {
1722
+ return [
1723
+ 'Delete',
1724
+ 'Input'
1725
+ ];
1726
+ }
1727
+ /**
1728
+ * @inheritDoc
1729
+ */ static get pluginName() {
1730
+ return 'TextTransformation';
1731
+ }
1732
+ /**
1733
+ * @inheritDoc
1734
+ */ init() {
1735
+ const model = this.editor.model;
1736
+ const modelSelection = model.document.selection;
1737
+ modelSelection.on('change:range', ()=>{
1738
+ // Disable plugin when selection is inside a code block.
1739
+ this.isEnabled = !modelSelection.anchor.parent.is('element', 'codeBlock');
1740
+ });
1741
+ this._enableTransformationWatchers();
1742
+ }
1743
+ /**
1744
+ * Create new TextWatcher listening to the editor for typing and selection events.
1745
+ */ _enableTransformationWatchers() {
1746
+ const editor = this.editor;
1747
+ const model = editor.model;
1748
+ const deletePlugin = editor.plugins.get('Delete');
1749
+ const normalizedTransformations = normalizeTransformations(editor.config.get('typing.transformations'));
1750
+ const testCallback = (text)=>{
1751
+ for (const normalizedTransformation of normalizedTransformations){
1752
+ const from = normalizedTransformation.from;
1753
+ const match = from.test(text);
1754
+ if (match) {
1755
+ return {
1756
+ normalizedTransformation
1757
+ };
1758
+ }
1759
+ }
1760
+ };
1761
+ const watcher = new TextWatcher(editor.model, testCallback);
1762
+ watcher.on('matched:data', (evt, data)=>{
1763
+ if (!data.batch.isTyping) {
1764
+ return;
1765
+ }
1766
+ const { from, to } = data.normalizedTransformation;
1767
+ const matches = from.exec(data.text);
1768
+ const replaces = to(matches.slice(1));
1769
+ const matchedRange = data.range;
1770
+ let changeIndex = matches.index;
1771
+ model.enqueueChange((writer)=>{
1772
+ for(let i = 1; i < matches.length; i++){
1773
+ const match = matches[i];
1774
+ const replaceWith = replaces[i - 1];
1775
+ if (replaceWith == null) {
1776
+ changeIndex += match.length;
1777
+ continue;
1778
+ }
1779
+ const replacePosition = matchedRange.start.getShiftedBy(changeIndex);
1780
+ const replaceRange = model.createRange(replacePosition, replacePosition.getShiftedBy(match.length));
1781
+ const attributes = getTextAttributesAfterPosition(replacePosition);
1782
+ model.insertContent(writer.createText(replaceWith, attributes), replaceRange);
1783
+ changeIndex += replaceWith.length;
1784
+ }
1785
+ model.enqueueChange(()=>{
1786
+ deletePlugin.requestUndoOnBackspace();
1787
+ });
1788
+ });
1789
+ });
1790
+ watcher.bind('isEnabled').to(this);
1791
+ }
1792
+ /**
1793
+ * @inheritDoc
1794
+ */ constructor(editor){
1795
+ super(editor);
1796
+ editor.config.define('typing', {
1797
+ transformations: {
1798
+ include: DEFAULT_TRANSFORMATIONS
1799
+ }
1800
+ });
1801
+ }
1802
+ }
1803
+ /**
1804
+ * Normalizes the configuration `from` parameter value.
1805
+ * The normalized value for the `from` parameter is a RegExp instance. If the passed `from` is already a RegExp instance,
1806
+ * it is returned unchanged.
1807
+ */ function normalizeFrom(from) {
1808
+ if (typeof from == 'string') {
1809
+ return new RegExp(`(${escapeRegExp(from)})$`);
1810
+ }
1811
+ // `from` is already a regular expression.
1812
+ return from;
1813
+ }
1814
+ /**
1815
+ * Normalizes the configuration `to` parameter value.
1816
+ * The normalized value for the `to` parameter is a function that takes an array and returns an array. See more in the
1817
+ * configuration description. If the passed `to` is already a function, it is returned unchanged.
1818
+ */ function normalizeTo(to) {
1819
+ if (typeof to == 'string') {
1820
+ return ()=>[
1821
+ to
1822
+ ];
1823
+ } else if (to instanceof Array) {
1824
+ return ()=>to;
1825
+ }
1826
+ // `to` is already a function.
1827
+ return to;
1828
+ }
1829
+ /**
1830
+ * For given `position` returns attributes for the text that is after that position.
1831
+ * The text can be in the same text node as the position (`foo[]bar`) or in the next text node (`foo[]<$text bold="true">bar</$text>`).
1832
+ */ function getTextAttributesAfterPosition(position) {
1833
+ const textNode = position.textNode ? position.textNode : position.nodeAfter;
1834
+ return textNode.getAttributes();
1835
+ }
1836
+ /**
1837
+ * Returns a RegExp pattern string that detects a sentence inside a quote.
1838
+ *
1839
+ * @param quoteCharacter The character to create a pattern for.
1840
+ */ function buildQuotesRegExp(quoteCharacter) {
1841
+ return new RegExp(`(^|\\s)(${quoteCharacter})([^${quoteCharacter}]*)(${quoteCharacter})$`);
1842
+ }
1843
+ /**
1844
+ * Reads text transformation config and returns normalized array of transformations objects.
1845
+ */ function normalizeTransformations(config) {
1846
+ const extra = config.extra || [];
1847
+ const remove = config.remove || [];
1848
+ const isNotRemoved = (transformation)=>!remove.includes(transformation);
1849
+ const configured = config.include.concat(extra).filter(isNotRemoved);
1850
+ return expandGroupsAndRemoveDuplicates(configured).filter(isNotRemoved) // Filter out 'remove' transformations as they might be set in group.
1851
+ .map((transformation)=>typeof transformation == 'string' && TRANSFORMATIONS[transformation] ? TRANSFORMATIONS[transformation] : transformation)// Filter out transformations set as string that has not been found.
1852
+ .filter((transformation)=>typeof transformation === 'object').map((transformation)=>({
1853
+ from: normalizeFrom(transformation.from),
1854
+ to: normalizeTo(transformation.to)
1855
+ }));
1856
+ }
1857
+ /**
1858
+ * Reads definitions and expands named groups if needed to transformation names.
1859
+ * This method also removes duplicated named transformations if any.
1860
+ */ function expandGroupsAndRemoveDuplicates(definitions) {
1861
+ // Set is using to make sure that transformation names are not duplicated.
1862
+ const definedTransformations = new Set();
1863
+ for (const transformationOrGroup of definitions){
1864
+ if (typeof transformationOrGroup == 'string' && TRANSFORMATION_GROUPS[transformationOrGroup]) {
1865
+ for (const transformation of TRANSFORMATION_GROUPS[transformationOrGroup]){
1866
+ definedTransformations.add(transformation);
1867
+ }
1868
+ } else {
1869
+ definedTransformations.add(transformationOrGroup);
1870
+ }
1871
+ }
1872
+ return Array.from(definedTransformations);
1873
+ }
1874
+
1875
+ /**
1876
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
1877
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
1878
+ */ /**
1879
+ * Returns a model range that covers all consecutive nodes with the same `attributeName` and its `value`
1880
+ * that intersect the given `position`.
1881
+ *
1882
+ * It can be used e.g. to get the entire range on which the `linkHref` attribute needs to be changed when having a
1883
+ * selection inside a link.
1884
+ *
1885
+ * @param position The start position.
1886
+ * @param attributeName The attribute name.
1887
+ * @param value The attribute value.
1888
+ * @param model The model instance.
1889
+ * @returns The link range.
1890
+ */ function findAttributeRange(position, attributeName, value, model) {
1891
+ return model.createRange(findAttributeRangeBound(position, attributeName, value, true, model), findAttributeRangeBound(position, attributeName, value, false, model));
1892
+ }
1893
+ /**
1894
+ * Walks forward or backward (depends on the `lookBack` flag), node by node, as long as they have the same attribute value
1895
+ * and returns a position just before or after (depends on the `lookBack` flag) the last matched node.
1896
+ *
1897
+ * @param position The start position.
1898
+ * @param attributeName The attribute name.
1899
+ * @param value The attribute value.
1900
+ * @param lookBack Whether the walk direction is forward (`false`) or backward (`true`).
1901
+ * @returns The position just before the last matched node.
1902
+ */ function findAttributeRangeBound(position, attributeName, value, lookBack, model) {
1903
+ // Get node before or after position (depends on `lookBack` flag).
1904
+ // When position is inside text node then start searching from text node.
1905
+ let node = position.textNode || (lookBack ? position.nodeBefore : position.nodeAfter);
1906
+ let lastNode = null;
1907
+ while(node && node.getAttribute(attributeName) == value){
1908
+ lastNode = node;
1909
+ node = lookBack ? node.previousSibling : node.nextSibling;
1910
+ }
1911
+ return lastNode ? model.createPositionAt(lastNode, lookBack ? 'before' : 'after') : position;
1912
+ }
1913
+
1914
+ /**
1915
+ * Adds a visual highlight style to an attribute element in which the selection is anchored.
1916
+ * Together with two-step caret movement, they indicate that the user is typing inside the element.
1917
+ *
1918
+ * Highlight is turned on by adding the given class to the attribute element in the view:
1919
+ *
1920
+ * * The class is removed before the conversion has started, as callbacks added with the `'highest'` priority
1921
+ * to {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} events.
1922
+ * * The class is added in the view post fixer, after other changes in the model tree were converted to the view.
1923
+ *
1924
+ * This way, adding and removing the highlight does not interfere with conversion.
1925
+ *
1926
+ * Usage:
1927
+ *
1928
+ * ```ts
1929
+ * import inlineHighlight from '@ckeditor/ckeditor5-typing/src/utils/inlinehighlight';
1930
+ *
1931
+ * // Make `ck-link_selected` class be applied on an `a` element
1932
+ * // whenever the corresponding `linkHref` attribute element is selected.
1933
+ * inlineHighlight( editor, 'linkHref', 'a', 'ck-link_selected' );
1934
+ * ```
1935
+ *
1936
+ * @param editor The editor instance.
1937
+ * @param attributeName The attribute name to check.
1938
+ * @param tagName The tagName of a view item.
1939
+ * @param className The class name to apply in the view.
1940
+ */ function inlineHighlight(editor, attributeName, tagName, className) {
1941
+ const view = editor.editing.view;
1942
+ const highlightedElements = new Set();
1943
+ // Adding the class.
1944
+ view.document.registerPostFixer((writer)=>{
1945
+ const selection = editor.model.document.selection;
1946
+ let changed = false;
1947
+ if (selection.hasAttribute(attributeName)) {
1948
+ const modelRange = findAttributeRange(selection.getFirstPosition(), attributeName, selection.getAttribute(attributeName), editor.model);
1949
+ const viewRange = editor.editing.mapper.toViewRange(modelRange);
1950
+ // There might be multiple view elements in the `viewRange`, for example, when the `a` element is
1951
+ // broken by a UIElement.
1952
+ for (const item of viewRange.getItems()){
1953
+ if (item.is('element', tagName) && !item.hasClass(className)) {
1954
+ writer.addClass(className, item);
1955
+ highlightedElements.add(item);
1956
+ changed = true;
1957
+ }
1958
+ }
1959
+ }
1960
+ return changed;
1961
+ });
1962
+ // Removing the class.
1963
+ editor.conversion.for('editingDowncast').add((dispatcher)=>{
1964
+ // Make sure the highlight is removed on every possible event, before conversion is started.
1965
+ dispatcher.on('insert', removeHighlight, {
1966
+ priority: 'highest'
1967
+ });
1968
+ dispatcher.on('remove', removeHighlight, {
1969
+ priority: 'highest'
1970
+ });
1971
+ dispatcher.on('attribute', removeHighlight, {
1972
+ priority: 'highest'
1973
+ });
1974
+ dispatcher.on('selection', removeHighlight, {
1975
+ priority: 'highest'
1976
+ });
1977
+ function removeHighlight() {
1978
+ view.change((writer)=>{
1979
+ for (const item of highlightedElements.values()){
1980
+ writer.removeClass(className, item);
1981
+ highlightedElements.delete(item);
1982
+ }
1983
+ });
1984
+ }
1985
+ });
1986
+ }
1987
+
1988
+ export { Delete, Input, InsertTextCommand, TextTransformation, TextWatcher, TwoStepCaretMovement, Typing, findAttributeRange, findAttributeRangeBound, getLastTextLine, inlineHighlight };
1989
+ //# sourceMappingURL=index.js.map