@ckeditor/ckeditor5-widget 38.0.1 → 38.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,773 +1,773 @@
1
- /**
2
- * @license Copyright (c) 2003-2023, 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
- /* global DOMParser */
6
- /**
7
- * @module widget/widgettypearound/widgettypearound
8
- */
9
- import { Plugin } from '@ckeditor/ckeditor5-core';
10
- import { Template } from '@ckeditor/ckeditor5-ui';
11
- import { Enter } from '@ckeditor/ckeditor5-enter';
12
- import { Delete } from '@ckeditor/ckeditor5-typing';
13
- import { env, isForwardArrowKeyCode } from '@ckeditor/ckeditor5-utils';
14
- import { isTypeAroundWidget, getClosestTypeAroundDomButton, getTypeAroundButtonPosition, getClosestWidgetViewElement, getTypeAroundFakeCaretPosition, TYPE_AROUND_SELECTION_ATTRIBUTE } from './utils';
15
- import { isWidget } from '../utils';
16
- import returnIcon from '../../theme/icons/return-arrow.svg';
17
- import '../../theme/widgettypearound.css';
18
- const POSSIBLE_INSERTION_POSITIONS = ['before', 'after'];
19
- // Do the SVG parsing once and then clone the result <svg> DOM element for each new button.
20
- const RETURN_ARROW_ICON_ELEMENT = new DOMParser().parseFromString(returnIcon, 'image/svg+xml').firstChild;
21
- const PLUGIN_DISABLED_EDITING_ROOT_CLASS = 'ck-widget__type-around_disabled';
22
- /**
23
- * A plugin that allows users to type around widgets where normally it is impossible to place the caret due
24
- * to limitations of web browsers. These "tight spots" occur, for instance, before (or after) a widget being
25
- * the first (or last) child of its parent or between two block widgets.
26
- *
27
- * This plugin extends the {@link module:widget/widget~Widget `Widget`} plugin and injects the user interface
28
- * with two buttons into each widget instance in the editor. Each of the buttons can be clicked by the
29
- * user if the widget is next to the "tight spot". Once clicked, a paragraph is created with the selection anchored
30
- * in it so that users can type (or insert content, paste, etc.) straight away.
31
- */
32
- export default class WidgetTypeAround extends Plugin {
33
- constructor() {
34
- super(...arguments);
35
- /**
36
- * A reference to the model widget element that has the fake caret active
37
- * on either side of it. It is later used to remove CSS classes associated with the fake caret
38
- * when the widget no longer needs it.
39
- */
40
- this._currentFakeCaretModelElement = null;
41
- }
42
- /**
43
- * @inheritDoc
44
- */
45
- static get pluginName() {
46
- return 'WidgetTypeAround';
47
- }
48
- /**
49
- * @inheritDoc
50
- */
51
- static get requires() {
52
- return [Enter, Delete];
53
- }
54
- /**
55
- * @inheritDoc
56
- */
57
- init() {
58
- const editor = this.editor;
59
- const editingView = editor.editing.view;
60
- // Set a CSS class on the view editing root when the plugin is disabled so all the buttons
61
- // and lines visually disappear. All the interactions are disabled in individual plugin methods.
62
- this.on('change:isEnabled', (evt, data, isEnabled) => {
63
- editingView.change(writer => {
64
- for (const root of editingView.document.roots) {
65
- if (isEnabled) {
66
- writer.removeClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
67
- }
68
- else {
69
- writer.addClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
70
- }
71
- }
72
- });
73
- if (!isEnabled) {
74
- editor.model.change(writer => {
75
- writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
76
- });
77
- }
78
- });
79
- this._enableTypeAroundUIInjection();
80
- this._enableInsertingParagraphsOnButtonClick();
81
- this._enableInsertingParagraphsOnEnterKeypress();
82
- this._enableInsertingParagraphsOnTypingKeystroke();
83
- this._enableTypeAroundFakeCaretActivationUsingKeyboardArrows();
84
- this._enableDeleteIntegration();
85
- this._enableInsertContentIntegration();
86
- this._enableInsertObjectIntegration();
87
- this._enableDeleteContentIntegration();
88
- }
89
- /**
90
- * @inheritDoc
91
- */
92
- destroy() {
93
- super.destroy();
94
- this._currentFakeCaretModelElement = null;
95
- }
96
- /**
97
- * Inserts a new paragraph next to a widget element with the selection anchored in it.
98
- *
99
- * **Note**: This method is heavily user-oriented and will both focus the editing view and scroll
100
- * the viewport to the selection in the inserted paragraph.
101
- *
102
- * @param widgetModelElement The model widget element next to which a paragraph is inserted.
103
- * @param position The position where the paragraph is inserted. Either `'before'` or `'after'` the widget.
104
- */
105
- _insertParagraph(widgetModelElement, position) {
106
- const editor = this.editor;
107
- const editingView = editor.editing.view;
108
- const attributesToCopy = editor.model.schema.getAttributesWithProperty(widgetModelElement, 'copyOnReplace', true);
109
- editor.execute('insertParagraph', {
110
- position: editor.model.createPositionAt(widgetModelElement, position),
111
- attributes: attributesToCopy
112
- });
113
- editingView.focus();
114
- editingView.scrollToTheSelection();
115
- }
116
- /**
117
- * A wrapper for the {@link module:utils/emittermixin~Emitter#listenTo} method that executes the callbacks only
118
- * when the plugin {@link #isEnabled is enabled}.
119
- *
120
- * @param emitter The object that fires the event.
121
- * @param event The name of the event.
122
- * @param callback The function to be called on event.
123
- * @param options Additional options.
124
- * @param options.priority The priority of this event callback. The higher the priority value the sooner
125
- * the callback will be fired. Events having the same priority are called in the order they were added.
126
- */
127
- _listenToIfEnabled(emitter, event, callback, options) {
128
- this.listenTo(emitter, event, (...args) => {
129
- // Do not respond if the plugin is disabled.
130
- if (this.isEnabled) {
131
- callback(...args);
132
- }
133
- }, options);
134
- }
135
- /**
136
- * Similar to {@link #_insertParagraph}, this method inserts a paragraph except that it
137
- * does not expect a position. Instead, it performs the insertion next to a selected widget
138
- * according to the `widget-type-around` model selection attribute value (fake caret position).
139
- *
140
- * Because this method requires the `widget-type-around` attribute to be set,
141
- * the insertion can only happen when the widget's fake caret is active (e.g. activated
142
- * using the keyboard).
143
- *
144
- * @returns Returns `true` when the paragraph was inserted (the attribute was present) and `false` otherwise.
145
- */
146
- _insertParagraphAccordingToFakeCaretPosition() {
147
- const editor = this.editor;
148
- const model = editor.model;
149
- const modelSelection = model.document.selection;
150
- const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection);
151
- if (!typeAroundFakeCaretPosition) {
152
- return false;
153
- }
154
- // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
155
- // @if CK_DEBUG_TYPING // console.info( '%c[WidgetTypeAround]%c Fake caret -> insert paragraph',
156
- // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green', ''
157
- // @if CK_DEBUG_TYPING // );
158
- // @if CK_DEBUG_TYPING // }
159
- const selectedModelElement = modelSelection.getSelectedElement();
160
- this._insertParagraph(selectedModelElement, typeAroundFakeCaretPosition);
161
- return true;
162
- }
163
- /**
164
- * Creates a listener in the editing conversion pipeline that injects the widget type around
165
- * UI into every single widget instance created in the editor.
166
- *
167
- * The UI is delivered as a {@link module:engine/view/uielement~UIElement}
168
- * wrapper which renders DOM buttons that users can use to insert paragraphs.
169
- */
170
- _enableTypeAroundUIInjection() {
171
- const editor = this.editor;
172
- const schema = editor.model.schema;
173
- const t = editor.locale.t;
174
- const buttonTitles = {
175
- before: t('Insert paragraph before block'),
176
- after: t('Insert paragraph after block')
177
- };
178
- editor.editing.downcastDispatcher.on('insert', (evt, data, conversionApi) => {
179
- const viewElement = conversionApi.mapper.toViewElement(data.item);
180
- if (!viewElement) {
181
- return;
182
- }
183
- // Filter out non-widgets and inline widgets.
184
- if (isTypeAroundWidget(viewElement, data.item, schema)) {
185
- injectUIIntoWidget(conversionApi.writer, buttonTitles, viewElement);
186
- const widgetLabel = viewElement.getCustomProperty('widgetLabel');
187
- widgetLabel.push(() => {
188
- return this.isEnabled ? t('Press Enter to type after or press Shift + Enter to type before the widget') : '';
189
- });
190
- }
191
- }, { priority: 'low' });
192
- }
193
- /**
194
- * Brings support for the fake caret that appears when either:
195
- *
196
- * * the selection moves to a widget from a position next to it using arrow keys,
197
- * * the arrow key is pressed when the widget is already selected.
198
- *
199
- * The fake caret lets the user know that they can start typing or just press
200
- * <kbd>Enter</kbd> to insert a paragraph at the position next to a widget as suggested by the fake caret.
201
- *
202
- * The fake caret disappears when the user changes the selection or the editor
203
- * gets blurred.
204
- *
205
- * The whole idea is as follows:
206
- *
207
- * 1. A user does one of the 2 scenarios described at the beginning.
208
- * 2. The "keydown" listener is executed and the decision is made whether to show or hide the fake caret.
209
- * 3. If it should show up, the `widget-type-around` model selection attribute is set indicating
210
- * on which side of the widget it should appear.
211
- * 4. The selection dispatcher reacts to the selection attribute and sets CSS classes responsible for the
212
- * fake caret on the view widget.
213
- * 5. If the fake caret should disappear, the selection attribute is removed and the dispatcher
214
- * does the CSS class clean-up in the view.
215
- * 6. Additionally, `change:range` and `FocusTracker#isFocused` listeners also remove the selection
216
- * attribute (the former also removes widget CSS classes).
217
- */
218
- _enableTypeAroundFakeCaretActivationUsingKeyboardArrows() {
219
- const editor = this.editor;
220
- const model = editor.model;
221
- const modelSelection = model.document.selection;
222
- const schema = model.schema;
223
- const editingView = editor.editing.view;
224
- // This is the main listener responsible for the fake caret.
225
- // Note: The priority must precede the default Widget class keydown handler ("high").
226
- this._listenToIfEnabled(editingView.document, 'arrowKey', (evt, domEventData) => {
227
- this._handleArrowKeyPress(evt, domEventData);
228
- }, { context: [isWidget, '$text'], priority: 'high' });
229
- // This listener makes sure the widget type around selection attribute will be gone from the model
230
- // selection as soon as the model range changes. This attribute only makes sense when a widget is selected
231
- // (and the "fake horizontal caret" is visible) so whenever the range changes (e.g. selection moved somewhere else),
232
- // let's get rid of the attribute so that the selection downcast dispatcher isn't even bothered.
233
- this._listenToIfEnabled(modelSelection, 'change:range', (evt, data) => {
234
- // Do not reset the selection attribute when the change was indirect.
235
- if (!data.directChange) {
236
- return;
237
- }
238
- // Get rid of the widget type around attribute of the selection on every change:range.
239
- // If the range changes, it means for sure, the user is no longer in the active ("fake horizontal caret") mode.
240
- editor.model.change(writer => {
241
- writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
242
- });
243
- });
244
- // Get rid of the widget type around attribute of the selection on every document change
245
- // that makes widget not selected any more (i.e. widget was removed).
246
- this._listenToIfEnabled(model.document, 'change:data', () => {
247
- const selectedModelElement = modelSelection.getSelectedElement();
248
- if (selectedModelElement) {
249
- const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
250
- if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
251
- return;
252
- }
253
- }
254
- editor.model.change(writer => {
255
- writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
256
- });
257
- });
258
- // React to changes of the model selection attribute made by the arrow keys listener.
259
- // If the block widget is selected and the attribute changes, downcast the attribute to special
260
- // CSS classes associated with the active ("fake horizontal caret") mode of the widget.
261
- this._listenToIfEnabled(editor.editing.downcastDispatcher, 'selection', (evt, data, conversionApi) => {
262
- const writer = conversionApi.writer;
263
- if (this._currentFakeCaretModelElement) {
264
- const selectedViewElement = conversionApi.mapper.toViewElement(this._currentFakeCaretModelElement);
265
- if (selectedViewElement) {
266
- // Get rid of CSS classes associated with the active ("fake horizontal caret") mode from the view widget.
267
- writer.removeClass(POSSIBLE_INSERTION_POSITIONS.map(positionToWidgetCssClass), selectedViewElement);
268
- this._currentFakeCaretModelElement = null;
269
- }
270
- }
271
- const selectedModelElement = data.selection.getSelectedElement();
272
- if (!selectedModelElement) {
273
- return;
274
- }
275
- const selectedViewElement = conversionApi.mapper.toViewElement(selectedModelElement);
276
- if (!isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
277
- return;
278
- }
279
- const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(data.selection);
280
- if (!typeAroundFakeCaretPosition) {
281
- return;
282
- }
283
- writer.addClass(positionToWidgetCssClass(typeAroundFakeCaretPosition), selectedViewElement);
284
- // Remember the view widget that got the "fake-caret" CSS class. This class should be removed ASAP when the
285
- // selection changes
286
- this._currentFakeCaretModelElement = selectedModelElement;
287
- });
288
- this._listenToIfEnabled(editor.ui.focusTracker, 'change:isFocused', (evt, name, isFocused) => {
289
- if (!isFocused) {
290
- editor.model.change(writer => {
291
- writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
292
- });
293
- }
294
- });
295
- function positionToWidgetCssClass(position) {
296
- return `ck-widget_type-around_show-fake-caret_${position}`;
297
- }
298
- }
299
- /**
300
- * A listener executed on each "keydown" in the view document, a part of
301
- * {@link #_enableTypeAroundFakeCaretActivationUsingKeyboardArrows}.
302
- *
303
- * It decides whether the arrow keypress should activate the fake caret or not (also whether it should
304
- * be deactivated).
305
- *
306
- * The fake caret activation is done by setting the `widget-type-around` model selection attribute
307
- * in this listener, and stopping and preventing the event that would normally be handled by the widget
308
- * plugin that is responsible for the regular keyboard navigation near/across all widgets (that
309
- * includes inline widgets, which are ignored by the widget type around plugin).
310
- */
311
- _handleArrowKeyPress(evt, domEventData) {
312
- const editor = this.editor;
313
- const model = editor.model;
314
- const modelSelection = model.document.selection;
315
- const schema = model.schema;
316
- const editingView = editor.editing.view;
317
- const keyCode = domEventData.keyCode;
318
- const isForward = isForwardArrowKeyCode(keyCode, editor.locale.contentLanguageDirection);
319
- const selectedViewElement = editingView.document.selection.getSelectedElement();
320
- const selectedModelElement = editor.editing.mapper.toModelElement(selectedViewElement);
321
- let shouldStopAndPreventDefault;
322
- // Handle keyboard navigation when a type-around-compatible widget is currently selected.
323
- if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
324
- shouldStopAndPreventDefault = this._handleArrowKeyPressOnSelectedWidget(isForward);
325
- }
326
- // Handle keyboard arrow navigation when the selection is next to a type-around-compatible widget
327
- // and the widget is about to be selected.
328
- else if (modelSelection.isCollapsed) {
329
- shouldStopAndPreventDefault = this._handleArrowKeyPressWhenSelectionNextToAWidget(isForward);
330
- }
331
- // Handle collapsing a non-collapsed selection that is wider than on a single widget.
332
- else if (!domEventData.shiftKey) {
333
- shouldStopAndPreventDefault = this._handleArrowKeyPressWhenNonCollapsedSelection(isForward);
334
- }
335
- if (shouldStopAndPreventDefault) {
336
- domEventData.preventDefault();
337
- evt.stop();
338
- }
339
- }
340
- /**
341
- * Handles the keyboard navigation on "keydown" when a widget is currently selected and activates or deactivates
342
- * the fake caret for that widget, depending on the current value of the `widget-type-around` model
343
- * selection attribute and the direction of the pressed arrow key.
344
- *
345
- * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
346
- * as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
347
- * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
348
- * process the event any further. Returns `false` otherwise.
349
- */
350
- _handleArrowKeyPressOnSelectedWidget(isForward) {
351
- const editor = this.editor;
352
- const model = editor.model;
353
- const modelSelection = model.document.selection;
354
- const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection);
355
- return model.change(writer => {
356
- // If the fake caret is displayed...
357
- if (typeAroundFakeCaretPosition) {
358
- const isLeavingWidget = typeAroundFakeCaretPosition === (isForward ? 'after' : 'before');
359
- // If the keyboard arrow works against the value of the selection attribute...
360
- // then remove the selection attribute but prevent default DOM actions
361
- // and do not let the Widget plugin listener move the selection. This brings
362
- // the widget back to the state, for instance, like if was selected using the mouse.
363
- //
364
- // **Note**: If leaving the widget when the fake caret is active, then the default
365
- // Widget handler will change the selection and, in turn, this will automatically discard
366
- // the selection attribute.
367
- if (!isLeavingWidget) {
368
- writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
369
- return true;
370
- }
371
- }
372
- // If the fake caret wasn't displayed, let's set it now according to the direction of the arrow
373
- // key press. This also means we cannot let the Widget plugin listener move the selection.
374
- else {
375
- writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before');
376
- return true;
377
- }
378
- return false;
379
- });
380
- }
381
- /**
382
- * Handles the keyboard navigation on "keydown" when **no** widget is selected but the selection is **directly** next
383
- * to one and upon the fake caret should become active for this widget upon arrow keypress
384
- * (AKA entering/selecting the widget).
385
- *
386
- * **Note**: This code mirrors the implementation from the widget plugin but also adds the selection attribute.
387
- * Unfortunately, there is no safe way to let the widget plugin do the selection part first and then just set the
388
- * selection attribute here in the widget type around plugin. This is why this code must duplicate some from the widget plugin.
389
- *
390
- * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
391
- * as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
392
- * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
393
- * process the event any further. Returns `false` otherwise.
394
- */
395
- _handleArrowKeyPressWhenSelectionNextToAWidget(isForward) {
396
- const editor = this.editor;
397
- const model = editor.model;
398
- const schema = model.schema;
399
- const widgetPlugin = editor.plugins.get('Widget');
400
- // This is the widget the selection is about to be set on.
401
- const modelElementNextToSelection = widgetPlugin._getObjectElementNextToSelection(isForward);
402
- const viewElementNextToSelection = editor.editing.mapper.toViewElement(modelElementNextToSelection);
403
- if (isTypeAroundWidget(viewElementNextToSelection, modelElementNextToSelection, schema)) {
404
- model.change(writer => {
405
- widgetPlugin._setSelectionOverElement(modelElementNextToSelection);
406
- writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'before' : 'after');
407
- });
408
- // The change() block above does the same job as the Widget plugin. The event can
409
- // be safely canceled.
410
- return true;
411
- }
412
- return false;
413
- }
414
- /**
415
- * Handles the keyboard navigation on "keydown" when a widget is currently selected (together with some other content)
416
- * and the widget is the first or last element in the selection. It activates or deactivates the fake caret for that widget.
417
- *
418
- * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
419
- * as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
420
- * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
421
- * process the event any further. Returns `false` otherwise.
422
- */
423
- _handleArrowKeyPressWhenNonCollapsedSelection(isForward) {
424
- const editor = this.editor;
425
- const model = editor.model;
426
- const schema = model.schema;
427
- const mapper = editor.editing.mapper;
428
- const modelSelection = model.document.selection;
429
- const selectedModelNode = isForward ?
430
- modelSelection.getLastPosition().nodeBefore :
431
- modelSelection.getFirstPosition().nodeAfter;
432
- const selectedViewNode = mapper.toViewElement(selectedModelNode);
433
- // There is a widget at the collapse position so collapse the selection to the fake caret on it.
434
- if (isTypeAroundWidget(selectedViewNode, selectedModelNode, schema)) {
435
- model.change(writer => {
436
- writer.setSelection(selectedModelNode, 'on');
437
- writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before');
438
- });
439
- return true;
440
- }
441
- return false;
442
- }
443
- /**
444
- * Registers a `mousedown` listener for the view document which intercepts events
445
- * coming from the widget type around UI, which happens when a user clicks one of the buttons
446
- * that insert a paragraph next to a widget.
447
- */
448
- _enableInsertingParagraphsOnButtonClick() {
449
- const editor = this.editor;
450
- const editingView = editor.editing.view;
451
- this._listenToIfEnabled(editingView.document, 'mousedown', (evt, domEventData) => {
452
- const button = getClosestTypeAroundDomButton(domEventData.domTarget);
453
- if (!button) {
454
- return;
455
- }
456
- const buttonPosition = getTypeAroundButtonPosition(button);
457
- const widgetViewElement = getClosestWidgetViewElement(button, editingView.domConverter);
458
- const widgetModelElement = editor.editing.mapper.toModelElement(widgetViewElement);
459
- this._insertParagraph(widgetModelElement, buttonPosition);
460
- domEventData.preventDefault();
461
- evt.stop();
462
- });
463
- }
464
- /**
465
- * Creates the <kbd>Enter</kbd> key listener on the view document that allows the user to insert a paragraph
466
- * near the widget when either:
467
- *
468
- * * The fake caret was first activated using the arrow keys,
469
- * * The entire widget is selected in the model.
470
- *
471
- * In the first case, the new paragraph is inserted according to the `widget-type-around` selection
472
- * attribute (see {@link #_handleArrowKeyPress}).
473
- *
474
- * In the second case, the new paragraph is inserted based on whether a soft (<kbd>Shift</kbd>+<kbd>Enter</kbd>) keystroke
475
- * was pressed or not.
476
- */
477
- _enableInsertingParagraphsOnEnterKeypress() {
478
- const editor = this.editor;
479
- const selection = editor.model.document.selection;
480
- const editingView = editor.editing.view;
481
- this._listenToIfEnabled(editingView.document, 'enter', (evt, domEventData) => {
482
- // This event could be triggered from inside the widget but we are interested
483
- // only when the widget is selected itself.
484
- if (evt.eventPhase != 'atTarget') {
485
- return;
486
- }
487
- const selectedModelElement = selection.getSelectedElement();
488
- const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
489
- const schema = editor.model.schema;
490
- let wasHandled;
491
- // First check if the widget is selected and there's a type around selection attribute associated
492
- // with the fake caret that would tell where to insert a new paragraph.
493
- if (this._insertParagraphAccordingToFakeCaretPosition()) {
494
- wasHandled = true;
495
- }
496
- // Then, if there is no selection attribute associated with the fake caret, check if the widget
497
- // simply is selected and create a new paragraph according to the keystroke (Shift+)Enter.
498
- else if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
499
- this._insertParagraph(selectedModelElement, domEventData.isSoft ? 'before' : 'after');
500
- wasHandled = true;
501
- }
502
- if (wasHandled) {
503
- domEventData.preventDefault();
504
- evt.stop();
505
- }
506
- }, { context: isWidget });
507
- }
508
- /**
509
- * Similar to the {@link #_enableInsertingParagraphsOnEnterKeypress}, it allows the user
510
- * to insert a paragraph next to a widget when the fake caret was activated using arrow
511
- * keys but it responds to typing instead of <kbd>Enter</kbd>.
512
- *
513
- * Listener enabled by this method will insert a new paragraph according to the `widget-type-around`
514
- * model selection attribute as the user simply starts typing, which creates the impression that the fake caret
515
- * behaves like a real one rendered by the browser (AKA your text appears where the caret was).
516
- *
517
- * **Note**: At the moment this listener creates 2 undo steps: one for the `insertParagraph` command
518
- * and another one for actual typing. It is not a disaster but this may need to be fixed
519
- * sooner or later.
520
- */
521
- _enableInsertingParagraphsOnTypingKeystroke() {
522
- const editor = this.editor;
523
- const viewDocument = editor.editing.view.document;
524
- // Note: The priority must precede the default Input plugin insertText handler.
525
- this._listenToIfEnabled(viewDocument, 'insertText', (evt, data) => {
526
- if (this._insertParagraphAccordingToFakeCaretPosition()) {
527
- // The view selection in the event data contains the widget. If the new paragraph
528
- // was inserted, modify the view selection passed along with the insertText event
529
- // so the default event handler in the Input plugin starts typing inside the paragraph.
530
- // Otherwise, the typing would be over the widget.
531
- data.selection = viewDocument.selection;
532
- }
533
- }, { priority: 'high' });
534
- if (env.isAndroid) {
535
- // On Android with English keyboard, the composition starts just by putting caret
536
- // at the word end or by selecting a table column. This is not a real composition started.
537
- // Trigger delete content on first composition key pressed.
538
- this._listenToIfEnabled(viewDocument, 'keydown', (evt, data) => {
539
- if (data.keyCode == 229) {
540
- this._insertParagraphAccordingToFakeCaretPosition();
541
- }
542
- });
543
- }
544
- else {
545
- // Note: The priority must precede the default Input plugin compositionstart handler (to call it before delete content).
546
- this._listenToIfEnabled(viewDocument, 'compositionstart', () => {
547
- this._insertParagraphAccordingToFakeCaretPosition();
548
- }, { priority: 'high' });
549
- }
550
- }
551
- /**
552
- * It creates a "delete" event listener on the view document to handle cases when the <kbd>Delete</kbd> or <kbd>Backspace</kbd>
553
- * is pressed and the fake caret is currently active.
554
- *
555
- * The fake caret should create an illusion of a real browser caret so that when it appears before or after
556
- * a widget, pressing <kbd>Delete</kbd> or <kbd>Backspace</kbd> should remove a widget or delete the content
557
- * before or after a widget (depending on the content surrounding the widget).
558
- */
559
- _enableDeleteIntegration() {
560
- const editor = this.editor;
561
- const editingView = editor.editing.view;
562
- const model = editor.model;
563
- const schema = model.schema;
564
- this._listenToIfEnabled(editingView.document, 'delete', (evt, domEventData) => {
565
- // This event could be triggered from inside the widget but we are interested
566
- // only when the widget is selected itself.
567
- if (evt.eventPhase != 'atTarget') {
568
- return;
569
- }
570
- const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(model.document.selection);
571
- // This listener handles only these cases when the fake caret is active.
572
- if (!typeAroundFakeCaretPosition) {
573
- return;
574
- }
575
- const direction = domEventData.direction;
576
- const selectedModelWidget = model.document.selection.getSelectedElement();
577
- const isFakeCaretBefore = typeAroundFakeCaretPosition === 'before';
578
- const isDeleteForward = direction == 'forward';
579
- const shouldDeleteEntireWidget = isFakeCaretBefore === isDeleteForward;
580
- if (shouldDeleteEntireWidget) {
581
- editor.execute('delete', {
582
- selection: model.createSelection(selectedModelWidget, 'on')
583
- });
584
- }
585
- else {
586
- const range = schema.getNearestSelectionRange(model.createPositionAt(selectedModelWidget, typeAroundFakeCaretPosition), direction);
587
- // If there is somewhere to move selection to, then there will be something to delete.
588
- if (range) {
589
- // If the range is NOT collapsed, then we know that the range contains an object (see getNearestSelectionRange() docs).
590
- if (!range.isCollapsed) {
591
- model.change(writer => {
592
- writer.setSelection(range);
593
- editor.execute(isDeleteForward ? 'deleteForward' : 'delete');
594
- });
595
- }
596
- else {
597
- const probe = model.createSelection(range.start);
598
- model.modifySelection(probe, { direction });
599
- // If the range is collapsed, let's see if a non-collapsed range exists that can could be deleted.
600
- // If such range exists, use the editor command because it it safe for collaboration (it merges where it can).
601
- if (!probe.focus.isEqual(range.start)) {
602
- model.change(writer => {
603
- writer.setSelection(range);
604
- editor.execute(isDeleteForward ? 'deleteForward' : 'delete');
605
- });
606
- }
607
- // If there is no non-collapsed range to be deleted then we are sure that there is an empty element
608
- // next to a widget that should be removed. "delete" and "deleteForward" commands cannot get rid of it
609
- // so calling Model#deleteContent here manually.
610
- else {
611
- const deepestEmptyRangeAncestor = getDeepestEmptyElementAncestor(schema, range.start.parent);
612
- model.deleteContent(model.createSelection(deepestEmptyRangeAncestor, 'on'), {
613
- doNotAutoparagraph: true
614
- });
615
- }
616
- }
617
- }
618
- }
619
- // If some content was deleted, don't let the handler from the Widget plugin kick in.
620
- // If nothing was deleted, then the default handler will have nothing to do anyway.
621
- domEventData.preventDefault();
622
- evt.stop();
623
- }, { context: isWidget });
624
- }
625
- /**
626
- * Attaches the {@link module:engine/model/model~Model#event:insertContent} event listener that, for instance, allows the user to paste
627
- * content near a widget when the fake caret is first activated using the arrow keys.
628
- *
629
- * The content is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
630
- */
631
- _enableInsertContentIntegration() {
632
- const editor = this.editor;
633
- const model = this.editor.model;
634
- const documentSelection = model.document.selection;
635
- this._listenToIfEnabled(editor.model, 'insertContent', (evt, [content, selectable]) => {
636
- if (selectable && !selectable.is('documentSelection')) {
637
- return;
638
- }
639
- const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
640
- if (!typeAroundFakeCaretPosition) {
641
- return;
642
- }
643
- evt.stop();
644
- return model.change(writer => {
645
- const selectedElement = documentSelection.getSelectedElement();
646
- const position = model.createPositionAt(selectedElement, typeAroundFakeCaretPosition);
647
- const selection = writer.createSelection(position);
648
- const result = model.insertContent(content, selection);
649
- writer.setSelection(selection);
650
- return result;
651
- });
652
- }, { priority: 'high' });
653
- }
654
- /**
655
- * Attaches the {@link module:engine/model/model~Model#event:insertObject} event listener that modifies the
656
- * `options.findOptimalPosition`parameter to position of fake caret in relation to selected element
657
- * to reflect user's intent of desired insertion position.
658
- *
659
- * The object is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
660
- */
661
- _enableInsertObjectIntegration() {
662
- const editor = this.editor;
663
- const model = this.editor.model;
664
- const documentSelection = model.document.selection;
665
- this._listenToIfEnabled(editor.model, 'insertObject', (evt, args) => {
666
- const [, selectable, options = {}] = args;
667
- if (selectable && !selectable.is('documentSelection')) {
668
- return;
669
- }
670
- const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
671
- if (!typeAroundFakeCaretPosition) {
672
- return;
673
- }
674
- options.findOptimalPosition = typeAroundFakeCaretPosition;
675
- args[3] = options;
676
- }, { priority: 'high' });
677
- }
678
- /**
679
- * Attaches the {@link module:engine/model/model~Model#event:deleteContent} event listener to block the event when the fake
680
- * caret is active.
681
- *
682
- * This is required for cases that trigger {@link module:engine/model/model~Model#deleteContent `model.deleteContent()`}
683
- * before calling {@link module:engine/model/model~Model#insertContent `model.insertContent()`} like, for instance,
684
- * plain text pasting.
685
- */
686
- _enableDeleteContentIntegration() {
687
- const editor = this.editor;
688
- const model = this.editor.model;
689
- const documentSelection = model.document.selection;
690
- this._listenToIfEnabled(editor.model, 'deleteContent', (evt, [selection]) => {
691
- if (selection && !selection.is('documentSelection')) {
692
- return;
693
- }
694
- const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
695
- // Disable removing the selection content while pasting plain text.
696
- if (typeAroundFakeCaretPosition) {
697
- evt.stop();
698
- }
699
- }, { priority: 'high' });
700
- }
701
- }
702
- /**
703
- * Injects the type around UI into a view widget instance.
704
- */
705
- function injectUIIntoWidget(viewWriter, buttonTitles, widgetViewElement) {
706
- const typeAroundWrapper = viewWriter.createUIElement('div', {
707
- class: 'ck ck-reset_all ck-widget__type-around'
708
- }, function (domDocument) {
709
- const wrapperDomElement = this.toDomElement(domDocument);
710
- injectButtons(wrapperDomElement, buttonTitles);
711
- injectFakeCaret(wrapperDomElement);
712
- return wrapperDomElement;
713
- });
714
- // Inject the type around wrapper into the widget's wrapper.
715
- viewWriter.insert(viewWriter.createPositionAt(widgetViewElement, 'end'), typeAroundWrapper);
716
- }
717
- /**
718
- * FYI: Not using the IconView class because each instance would need to be destroyed to avoid memory leaks
719
- * and it's pretty hard to figure out when a view (widget) is gone for good so it's cheaper to use raw
720
- * <svg> here.
721
- */
722
- function injectButtons(wrapperDomElement, buttonTitles) {
723
- for (const position of POSSIBLE_INSERTION_POSITIONS) {
724
- const buttonTemplate = new Template({
725
- tag: 'div',
726
- attributes: {
727
- class: [
728
- 'ck',
729
- 'ck-widget__type-around__button',
730
- `ck-widget__type-around__button_${position}`
731
- ],
732
- title: buttonTitles[position],
733
- 'aria-hidden': 'true'
734
- },
735
- children: [
736
- wrapperDomElement.ownerDocument.importNode(RETURN_ARROW_ICON_ELEMENT, true)
737
- ]
738
- });
739
- wrapperDomElement.appendChild(buttonTemplate.render());
740
- }
741
- }
742
- function injectFakeCaret(wrapperDomElement) {
743
- const caretTemplate = new Template({
744
- tag: 'div',
745
- attributes: {
746
- class: [
747
- 'ck',
748
- 'ck-widget__type-around__fake-caret'
749
- ]
750
- }
751
- });
752
- wrapperDomElement.appendChild(caretTemplate.render());
753
- }
754
- /**
755
- * Returns the ancestor of an element closest to the root which is empty. For instance,
756
- * for `<baz>`:
757
- *
758
- * ```
759
- * <foo>abc<bar><baz></baz></bar></foo>
760
- * ```
761
- *
762
- * it returns `<bar>`.
763
- */
764
- function getDeepestEmptyElementAncestor(schema, element) {
765
- let deepestEmptyAncestor = element;
766
- for (const ancestor of element.getAncestors({ parentFirst: true })) {
767
- if (ancestor.childCount > 1 || schema.isLimit(ancestor)) {
768
- break;
769
- }
770
- deepestEmptyAncestor = ancestor;
771
- }
772
- return deepestEmptyAncestor;
773
- }
1
+ /**
2
+ * @license Copyright (c) 2003-2023, 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
+ /* global DOMParser */
6
+ /**
7
+ * @module widget/widgettypearound/widgettypearound
8
+ */
9
+ import { Plugin } from '@ckeditor/ckeditor5-core';
10
+ import { Template } from '@ckeditor/ckeditor5-ui';
11
+ import { Enter } from '@ckeditor/ckeditor5-enter';
12
+ import { Delete } from '@ckeditor/ckeditor5-typing';
13
+ import { env, isForwardArrowKeyCode } from '@ckeditor/ckeditor5-utils';
14
+ import { isTypeAroundWidget, getClosestTypeAroundDomButton, getTypeAroundButtonPosition, getClosestWidgetViewElement, getTypeAroundFakeCaretPosition, TYPE_AROUND_SELECTION_ATTRIBUTE } from './utils';
15
+ import { isWidget } from '../utils';
16
+ import returnIcon from '../../theme/icons/return-arrow.svg';
17
+ import '../../theme/widgettypearound.css';
18
+ const POSSIBLE_INSERTION_POSITIONS = ['before', 'after'];
19
+ // Do the SVG parsing once and then clone the result <svg> DOM element for each new button.
20
+ const RETURN_ARROW_ICON_ELEMENT = new DOMParser().parseFromString(returnIcon, 'image/svg+xml').firstChild;
21
+ const PLUGIN_DISABLED_EDITING_ROOT_CLASS = 'ck-widget__type-around_disabled';
22
+ /**
23
+ * A plugin that allows users to type around widgets where normally it is impossible to place the caret due
24
+ * to limitations of web browsers. These "tight spots" occur, for instance, before (or after) a widget being
25
+ * the first (or last) child of its parent or between two block widgets.
26
+ *
27
+ * This plugin extends the {@link module:widget/widget~Widget `Widget`} plugin and injects the user interface
28
+ * with two buttons into each widget instance in the editor. Each of the buttons can be clicked by the
29
+ * user if the widget is next to the "tight spot". Once clicked, a paragraph is created with the selection anchored
30
+ * in it so that users can type (or insert content, paste, etc.) straight away.
31
+ */
32
+ export default class WidgetTypeAround extends Plugin {
33
+ constructor() {
34
+ super(...arguments);
35
+ /**
36
+ * A reference to the model widget element that has the fake caret active
37
+ * on either side of it. It is later used to remove CSS classes associated with the fake caret
38
+ * when the widget no longer needs it.
39
+ */
40
+ this._currentFakeCaretModelElement = null;
41
+ }
42
+ /**
43
+ * @inheritDoc
44
+ */
45
+ static get pluginName() {
46
+ return 'WidgetTypeAround';
47
+ }
48
+ /**
49
+ * @inheritDoc
50
+ */
51
+ static get requires() {
52
+ return [Enter, Delete];
53
+ }
54
+ /**
55
+ * @inheritDoc
56
+ */
57
+ init() {
58
+ const editor = this.editor;
59
+ const editingView = editor.editing.view;
60
+ // Set a CSS class on the view editing root when the plugin is disabled so all the buttons
61
+ // and lines visually disappear. All the interactions are disabled in individual plugin methods.
62
+ this.on('change:isEnabled', (evt, data, isEnabled) => {
63
+ editingView.change(writer => {
64
+ for (const root of editingView.document.roots) {
65
+ if (isEnabled) {
66
+ writer.removeClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
67
+ }
68
+ else {
69
+ writer.addClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
70
+ }
71
+ }
72
+ });
73
+ if (!isEnabled) {
74
+ editor.model.change(writer => {
75
+ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
76
+ });
77
+ }
78
+ });
79
+ this._enableTypeAroundUIInjection();
80
+ this._enableInsertingParagraphsOnButtonClick();
81
+ this._enableInsertingParagraphsOnEnterKeypress();
82
+ this._enableInsertingParagraphsOnTypingKeystroke();
83
+ this._enableTypeAroundFakeCaretActivationUsingKeyboardArrows();
84
+ this._enableDeleteIntegration();
85
+ this._enableInsertContentIntegration();
86
+ this._enableInsertObjectIntegration();
87
+ this._enableDeleteContentIntegration();
88
+ }
89
+ /**
90
+ * @inheritDoc
91
+ */
92
+ destroy() {
93
+ super.destroy();
94
+ this._currentFakeCaretModelElement = null;
95
+ }
96
+ /**
97
+ * Inserts a new paragraph next to a widget element with the selection anchored in it.
98
+ *
99
+ * **Note**: This method is heavily user-oriented and will both focus the editing view and scroll
100
+ * the viewport to the selection in the inserted paragraph.
101
+ *
102
+ * @param widgetModelElement The model widget element next to which a paragraph is inserted.
103
+ * @param position The position where the paragraph is inserted. Either `'before'` or `'after'` the widget.
104
+ */
105
+ _insertParagraph(widgetModelElement, position) {
106
+ const editor = this.editor;
107
+ const editingView = editor.editing.view;
108
+ const attributesToCopy = editor.model.schema.getAttributesWithProperty(widgetModelElement, 'copyOnReplace', true);
109
+ editor.execute('insertParagraph', {
110
+ position: editor.model.createPositionAt(widgetModelElement, position),
111
+ attributes: attributesToCopy
112
+ });
113
+ editingView.focus();
114
+ editingView.scrollToTheSelection();
115
+ }
116
+ /**
117
+ * A wrapper for the {@link module:utils/emittermixin~Emitter#listenTo} method that executes the callbacks only
118
+ * when the plugin {@link #isEnabled is enabled}.
119
+ *
120
+ * @param emitter The object that fires the event.
121
+ * @param event The name of the event.
122
+ * @param callback The function to be called on event.
123
+ * @param options Additional options.
124
+ * @param options.priority The priority of this event callback. The higher the priority value the sooner
125
+ * the callback will be fired. Events having the same priority are called in the order they were added.
126
+ */
127
+ _listenToIfEnabled(emitter, event, callback, options) {
128
+ this.listenTo(emitter, event, (...args) => {
129
+ // Do not respond if the plugin is disabled.
130
+ if (this.isEnabled) {
131
+ callback(...args);
132
+ }
133
+ }, options);
134
+ }
135
+ /**
136
+ * Similar to {@link #_insertParagraph}, this method inserts a paragraph except that it
137
+ * does not expect a position. Instead, it performs the insertion next to a selected widget
138
+ * according to the `widget-type-around` model selection attribute value (fake caret position).
139
+ *
140
+ * Because this method requires the `widget-type-around` attribute to be set,
141
+ * the insertion can only happen when the widget's fake caret is active (e.g. activated
142
+ * using the keyboard).
143
+ *
144
+ * @returns Returns `true` when the paragraph was inserted (the attribute was present) and `false` otherwise.
145
+ */
146
+ _insertParagraphAccordingToFakeCaretPosition() {
147
+ const editor = this.editor;
148
+ const model = editor.model;
149
+ const modelSelection = model.document.selection;
150
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection);
151
+ if (!typeAroundFakeCaretPosition) {
152
+ return false;
153
+ }
154
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
155
+ // @if CK_DEBUG_TYPING // console.info( '%c[WidgetTypeAround]%c Fake caret -> insert paragraph',
156
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green', ''
157
+ // @if CK_DEBUG_TYPING // );
158
+ // @if CK_DEBUG_TYPING // }
159
+ const selectedModelElement = modelSelection.getSelectedElement();
160
+ this._insertParagraph(selectedModelElement, typeAroundFakeCaretPosition);
161
+ return true;
162
+ }
163
+ /**
164
+ * Creates a listener in the editing conversion pipeline that injects the widget type around
165
+ * UI into every single widget instance created in the editor.
166
+ *
167
+ * The UI is delivered as a {@link module:engine/view/uielement~UIElement}
168
+ * wrapper which renders DOM buttons that users can use to insert paragraphs.
169
+ */
170
+ _enableTypeAroundUIInjection() {
171
+ const editor = this.editor;
172
+ const schema = editor.model.schema;
173
+ const t = editor.locale.t;
174
+ const buttonTitles = {
175
+ before: t('Insert paragraph before block'),
176
+ after: t('Insert paragraph after block')
177
+ };
178
+ editor.editing.downcastDispatcher.on('insert', (evt, data, conversionApi) => {
179
+ const viewElement = conversionApi.mapper.toViewElement(data.item);
180
+ if (!viewElement) {
181
+ return;
182
+ }
183
+ // Filter out non-widgets and inline widgets.
184
+ if (isTypeAroundWidget(viewElement, data.item, schema)) {
185
+ injectUIIntoWidget(conversionApi.writer, buttonTitles, viewElement);
186
+ const widgetLabel = viewElement.getCustomProperty('widgetLabel');
187
+ widgetLabel.push(() => {
188
+ return this.isEnabled ? t('Press Enter to type after or press Shift + Enter to type before the widget') : '';
189
+ });
190
+ }
191
+ }, { priority: 'low' });
192
+ }
193
+ /**
194
+ * Brings support for the fake caret that appears when either:
195
+ *
196
+ * * the selection moves to a widget from a position next to it using arrow keys,
197
+ * * the arrow key is pressed when the widget is already selected.
198
+ *
199
+ * The fake caret lets the user know that they can start typing or just press
200
+ * <kbd>Enter</kbd> to insert a paragraph at the position next to a widget as suggested by the fake caret.
201
+ *
202
+ * The fake caret disappears when the user changes the selection or the editor
203
+ * gets blurred.
204
+ *
205
+ * The whole idea is as follows:
206
+ *
207
+ * 1. A user does one of the 2 scenarios described at the beginning.
208
+ * 2. The "keydown" listener is executed and the decision is made whether to show or hide the fake caret.
209
+ * 3. If it should show up, the `widget-type-around` model selection attribute is set indicating
210
+ * on which side of the widget it should appear.
211
+ * 4. The selection dispatcher reacts to the selection attribute and sets CSS classes responsible for the
212
+ * fake caret on the view widget.
213
+ * 5. If the fake caret should disappear, the selection attribute is removed and the dispatcher
214
+ * does the CSS class clean-up in the view.
215
+ * 6. Additionally, `change:range` and `FocusTracker#isFocused` listeners also remove the selection
216
+ * attribute (the former also removes widget CSS classes).
217
+ */
218
+ _enableTypeAroundFakeCaretActivationUsingKeyboardArrows() {
219
+ const editor = this.editor;
220
+ const model = editor.model;
221
+ const modelSelection = model.document.selection;
222
+ const schema = model.schema;
223
+ const editingView = editor.editing.view;
224
+ // This is the main listener responsible for the fake caret.
225
+ // Note: The priority must precede the default Widget class keydown handler ("high").
226
+ this._listenToIfEnabled(editingView.document, 'arrowKey', (evt, domEventData) => {
227
+ this._handleArrowKeyPress(evt, domEventData);
228
+ }, { context: [isWidget, '$text'], priority: 'high' });
229
+ // This listener makes sure the widget type around selection attribute will be gone from the model
230
+ // selection as soon as the model range changes. This attribute only makes sense when a widget is selected
231
+ // (and the "fake horizontal caret" is visible) so whenever the range changes (e.g. selection moved somewhere else),
232
+ // let's get rid of the attribute so that the selection downcast dispatcher isn't even bothered.
233
+ this._listenToIfEnabled(modelSelection, 'change:range', (evt, data) => {
234
+ // Do not reset the selection attribute when the change was indirect.
235
+ if (!data.directChange) {
236
+ return;
237
+ }
238
+ // Get rid of the widget type around attribute of the selection on every change:range.
239
+ // If the range changes, it means for sure, the user is no longer in the active ("fake horizontal caret") mode.
240
+ editor.model.change(writer => {
241
+ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
242
+ });
243
+ });
244
+ // Get rid of the widget type around attribute of the selection on every document change
245
+ // that makes widget not selected any more (i.e. widget was removed).
246
+ this._listenToIfEnabled(model.document, 'change:data', () => {
247
+ const selectedModelElement = modelSelection.getSelectedElement();
248
+ if (selectedModelElement) {
249
+ const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
250
+ if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
251
+ return;
252
+ }
253
+ }
254
+ editor.model.change(writer => {
255
+ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
256
+ });
257
+ });
258
+ // React to changes of the model selection attribute made by the arrow keys listener.
259
+ // If the block widget is selected and the attribute changes, downcast the attribute to special
260
+ // CSS classes associated with the active ("fake horizontal caret") mode of the widget.
261
+ this._listenToIfEnabled(editor.editing.downcastDispatcher, 'selection', (evt, data, conversionApi) => {
262
+ const writer = conversionApi.writer;
263
+ if (this._currentFakeCaretModelElement) {
264
+ const selectedViewElement = conversionApi.mapper.toViewElement(this._currentFakeCaretModelElement);
265
+ if (selectedViewElement) {
266
+ // Get rid of CSS classes associated with the active ("fake horizontal caret") mode from the view widget.
267
+ writer.removeClass(POSSIBLE_INSERTION_POSITIONS.map(positionToWidgetCssClass), selectedViewElement);
268
+ this._currentFakeCaretModelElement = null;
269
+ }
270
+ }
271
+ const selectedModelElement = data.selection.getSelectedElement();
272
+ if (!selectedModelElement) {
273
+ return;
274
+ }
275
+ const selectedViewElement = conversionApi.mapper.toViewElement(selectedModelElement);
276
+ if (!isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
277
+ return;
278
+ }
279
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(data.selection);
280
+ if (!typeAroundFakeCaretPosition) {
281
+ return;
282
+ }
283
+ writer.addClass(positionToWidgetCssClass(typeAroundFakeCaretPosition), selectedViewElement);
284
+ // Remember the view widget that got the "fake-caret" CSS class. This class should be removed ASAP when the
285
+ // selection changes
286
+ this._currentFakeCaretModelElement = selectedModelElement;
287
+ });
288
+ this._listenToIfEnabled(editor.ui.focusTracker, 'change:isFocused', (evt, name, isFocused) => {
289
+ if (!isFocused) {
290
+ editor.model.change(writer => {
291
+ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
292
+ });
293
+ }
294
+ });
295
+ function positionToWidgetCssClass(position) {
296
+ return `ck-widget_type-around_show-fake-caret_${position}`;
297
+ }
298
+ }
299
+ /**
300
+ * A listener executed on each "keydown" in the view document, a part of
301
+ * {@link #_enableTypeAroundFakeCaretActivationUsingKeyboardArrows}.
302
+ *
303
+ * It decides whether the arrow keypress should activate the fake caret or not (also whether it should
304
+ * be deactivated).
305
+ *
306
+ * The fake caret activation is done by setting the `widget-type-around` model selection attribute
307
+ * in this listener, and stopping and preventing the event that would normally be handled by the widget
308
+ * plugin that is responsible for the regular keyboard navigation near/across all widgets (that
309
+ * includes inline widgets, which are ignored by the widget type around plugin).
310
+ */
311
+ _handleArrowKeyPress(evt, domEventData) {
312
+ const editor = this.editor;
313
+ const model = editor.model;
314
+ const modelSelection = model.document.selection;
315
+ const schema = model.schema;
316
+ const editingView = editor.editing.view;
317
+ const keyCode = domEventData.keyCode;
318
+ const isForward = isForwardArrowKeyCode(keyCode, editor.locale.contentLanguageDirection);
319
+ const selectedViewElement = editingView.document.selection.getSelectedElement();
320
+ const selectedModelElement = editor.editing.mapper.toModelElement(selectedViewElement);
321
+ let shouldStopAndPreventDefault;
322
+ // Handle keyboard navigation when a type-around-compatible widget is currently selected.
323
+ if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
324
+ shouldStopAndPreventDefault = this._handleArrowKeyPressOnSelectedWidget(isForward);
325
+ }
326
+ // Handle keyboard arrow navigation when the selection is next to a type-around-compatible widget
327
+ // and the widget is about to be selected.
328
+ else if (modelSelection.isCollapsed) {
329
+ shouldStopAndPreventDefault = this._handleArrowKeyPressWhenSelectionNextToAWidget(isForward);
330
+ }
331
+ // Handle collapsing a non-collapsed selection that is wider than on a single widget.
332
+ else if (!domEventData.shiftKey) {
333
+ shouldStopAndPreventDefault = this._handleArrowKeyPressWhenNonCollapsedSelection(isForward);
334
+ }
335
+ if (shouldStopAndPreventDefault) {
336
+ domEventData.preventDefault();
337
+ evt.stop();
338
+ }
339
+ }
340
+ /**
341
+ * Handles the keyboard navigation on "keydown" when a widget is currently selected and activates or deactivates
342
+ * the fake caret for that widget, depending on the current value of the `widget-type-around` model
343
+ * selection attribute and the direction of the pressed arrow key.
344
+ *
345
+ * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
346
+ * as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
347
+ * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
348
+ * process the event any further. Returns `false` otherwise.
349
+ */
350
+ _handleArrowKeyPressOnSelectedWidget(isForward) {
351
+ const editor = this.editor;
352
+ const model = editor.model;
353
+ const modelSelection = model.document.selection;
354
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection);
355
+ return model.change(writer => {
356
+ // If the fake caret is displayed...
357
+ if (typeAroundFakeCaretPosition) {
358
+ const isLeavingWidget = typeAroundFakeCaretPosition === (isForward ? 'after' : 'before');
359
+ // If the keyboard arrow works against the value of the selection attribute...
360
+ // then remove the selection attribute but prevent default DOM actions
361
+ // and do not let the Widget plugin listener move the selection. This brings
362
+ // the widget back to the state, for instance, like if was selected using the mouse.
363
+ //
364
+ // **Note**: If leaving the widget when the fake caret is active, then the default
365
+ // Widget handler will change the selection and, in turn, this will automatically discard
366
+ // the selection attribute.
367
+ if (!isLeavingWidget) {
368
+ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
369
+ return true;
370
+ }
371
+ }
372
+ // If the fake caret wasn't displayed, let's set it now according to the direction of the arrow
373
+ // key press. This also means we cannot let the Widget plugin listener move the selection.
374
+ else {
375
+ writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before');
376
+ return true;
377
+ }
378
+ return false;
379
+ });
380
+ }
381
+ /**
382
+ * Handles the keyboard navigation on "keydown" when **no** widget is selected but the selection is **directly** next
383
+ * to one and upon the fake caret should become active for this widget upon arrow keypress
384
+ * (AKA entering/selecting the widget).
385
+ *
386
+ * **Note**: This code mirrors the implementation from the widget plugin but also adds the selection attribute.
387
+ * Unfortunately, there is no safe way to let the widget plugin do the selection part first and then just set the
388
+ * selection attribute here in the widget type around plugin. This is why this code must duplicate some from the widget plugin.
389
+ *
390
+ * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
391
+ * as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
392
+ * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
393
+ * process the event any further. Returns `false` otherwise.
394
+ */
395
+ _handleArrowKeyPressWhenSelectionNextToAWidget(isForward) {
396
+ const editor = this.editor;
397
+ const model = editor.model;
398
+ const schema = model.schema;
399
+ const widgetPlugin = editor.plugins.get('Widget');
400
+ // This is the widget the selection is about to be set on.
401
+ const modelElementNextToSelection = widgetPlugin._getObjectElementNextToSelection(isForward);
402
+ const viewElementNextToSelection = editor.editing.mapper.toViewElement(modelElementNextToSelection);
403
+ if (isTypeAroundWidget(viewElementNextToSelection, modelElementNextToSelection, schema)) {
404
+ model.change(writer => {
405
+ widgetPlugin._setSelectionOverElement(modelElementNextToSelection);
406
+ writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'before' : 'after');
407
+ });
408
+ // The change() block above does the same job as the Widget plugin. The event can
409
+ // be safely canceled.
410
+ return true;
411
+ }
412
+ return false;
413
+ }
414
+ /**
415
+ * Handles the keyboard navigation on "keydown" when a widget is currently selected (together with some other content)
416
+ * and the widget is the first or last element in the selection. It activates or deactivates the fake caret for that widget.
417
+ *
418
+ * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
419
+ * as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
420
+ * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
421
+ * process the event any further. Returns `false` otherwise.
422
+ */
423
+ _handleArrowKeyPressWhenNonCollapsedSelection(isForward) {
424
+ const editor = this.editor;
425
+ const model = editor.model;
426
+ const schema = model.schema;
427
+ const mapper = editor.editing.mapper;
428
+ const modelSelection = model.document.selection;
429
+ const selectedModelNode = isForward ?
430
+ modelSelection.getLastPosition().nodeBefore :
431
+ modelSelection.getFirstPosition().nodeAfter;
432
+ const selectedViewNode = mapper.toViewElement(selectedModelNode);
433
+ // There is a widget at the collapse position so collapse the selection to the fake caret on it.
434
+ if (isTypeAroundWidget(selectedViewNode, selectedModelNode, schema)) {
435
+ model.change(writer => {
436
+ writer.setSelection(selectedModelNode, 'on');
437
+ writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before');
438
+ });
439
+ return true;
440
+ }
441
+ return false;
442
+ }
443
+ /**
444
+ * Registers a `mousedown` listener for the view document which intercepts events
445
+ * coming from the widget type around UI, which happens when a user clicks one of the buttons
446
+ * that insert a paragraph next to a widget.
447
+ */
448
+ _enableInsertingParagraphsOnButtonClick() {
449
+ const editor = this.editor;
450
+ const editingView = editor.editing.view;
451
+ this._listenToIfEnabled(editingView.document, 'mousedown', (evt, domEventData) => {
452
+ const button = getClosestTypeAroundDomButton(domEventData.domTarget);
453
+ if (!button) {
454
+ return;
455
+ }
456
+ const buttonPosition = getTypeAroundButtonPosition(button);
457
+ const widgetViewElement = getClosestWidgetViewElement(button, editingView.domConverter);
458
+ const widgetModelElement = editor.editing.mapper.toModelElement(widgetViewElement);
459
+ this._insertParagraph(widgetModelElement, buttonPosition);
460
+ domEventData.preventDefault();
461
+ evt.stop();
462
+ });
463
+ }
464
+ /**
465
+ * Creates the <kbd>Enter</kbd> key listener on the view document that allows the user to insert a paragraph
466
+ * near the widget when either:
467
+ *
468
+ * * The fake caret was first activated using the arrow keys,
469
+ * * The entire widget is selected in the model.
470
+ *
471
+ * In the first case, the new paragraph is inserted according to the `widget-type-around` selection
472
+ * attribute (see {@link #_handleArrowKeyPress}).
473
+ *
474
+ * In the second case, the new paragraph is inserted based on whether a soft (<kbd>Shift</kbd>+<kbd>Enter</kbd>) keystroke
475
+ * was pressed or not.
476
+ */
477
+ _enableInsertingParagraphsOnEnterKeypress() {
478
+ const editor = this.editor;
479
+ const selection = editor.model.document.selection;
480
+ const editingView = editor.editing.view;
481
+ this._listenToIfEnabled(editingView.document, 'enter', (evt, domEventData) => {
482
+ // This event could be triggered from inside the widget but we are interested
483
+ // only when the widget is selected itself.
484
+ if (evt.eventPhase != 'atTarget') {
485
+ return;
486
+ }
487
+ const selectedModelElement = selection.getSelectedElement();
488
+ const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
489
+ const schema = editor.model.schema;
490
+ let wasHandled;
491
+ // First check if the widget is selected and there's a type around selection attribute associated
492
+ // with the fake caret that would tell where to insert a new paragraph.
493
+ if (this._insertParagraphAccordingToFakeCaretPosition()) {
494
+ wasHandled = true;
495
+ }
496
+ // Then, if there is no selection attribute associated with the fake caret, check if the widget
497
+ // simply is selected and create a new paragraph according to the keystroke (Shift+)Enter.
498
+ else if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
499
+ this._insertParagraph(selectedModelElement, domEventData.isSoft ? 'before' : 'after');
500
+ wasHandled = true;
501
+ }
502
+ if (wasHandled) {
503
+ domEventData.preventDefault();
504
+ evt.stop();
505
+ }
506
+ }, { context: isWidget });
507
+ }
508
+ /**
509
+ * Similar to the {@link #_enableInsertingParagraphsOnEnterKeypress}, it allows the user
510
+ * to insert a paragraph next to a widget when the fake caret was activated using arrow
511
+ * keys but it responds to typing instead of <kbd>Enter</kbd>.
512
+ *
513
+ * Listener enabled by this method will insert a new paragraph according to the `widget-type-around`
514
+ * model selection attribute as the user simply starts typing, which creates the impression that the fake caret
515
+ * behaves like a real one rendered by the browser (AKA your text appears where the caret was).
516
+ *
517
+ * **Note**: At the moment this listener creates 2 undo steps: one for the `insertParagraph` command
518
+ * and another one for actual typing. It is not a disaster but this may need to be fixed
519
+ * sooner or later.
520
+ */
521
+ _enableInsertingParagraphsOnTypingKeystroke() {
522
+ const editor = this.editor;
523
+ const viewDocument = editor.editing.view.document;
524
+ // Note: The priority must precede the default Input plugin insertText handler.
525
+ this._listenToIfEnabled(viewDocument, 'insertText', (evt, data) => {
526
+ if (this._insertParagraphAccordingToFakeCaretPosition()) {
527
+ // The view selection in the event data contains the widget. If the new paragraph
528
+ // was inserted, modify the view selection passed along with the insertText event
529
+ // so the default event handler in the Input plugin starts typing inside the paragraph.
530
+ // Otherwise, the typing would be over the widget.
531
+ data.selection = viewDocument.selection;
532
+ }
533
+ }, { priority: 'high' });
534
+ if (env.isAndroid) {
535
+ // On Android with English keyboard, the composition starts just by putting caret
536
+ // at the word end or by selecting a table column. This is not a real composition started.
537
+ // Trigger delete content on first composition key pressed.
538
+ this._listenToIfEnabled(viewDocument, 'keydown', (evt, data) => {
539
+ if (data.keyCode == 229) {
540
+ this._insertParagraphAccordingToFakeCaretPosition();
541
+ }
542
+ });
543
+ }
544
+ else {
545
+ // Note: The priority must precede the default Input plugin compositionstart handler (to call it before delete content).
546
+ this._listenToIfEnabled(viewDocument, 'compositionstart', () => {
547
+ this._insertParagraphAccordingToFakeCaretPosition();
548
+ }, { priority: 'high' });
549
+ }
550
+ }
551
+ /**
552
+ * It creates a "delete" event listener on the view document to handle cases when the <kbd>Delete</kbd> or <kbd>Backspace</kbd>
553
+ * is pressed and the fake caret is currently active.
554
+ *
555
+ * The fake caret should create an illusion of a real browser caret so that when it appears before or after
556
+ * a widget, pressing <kbd>Delete</kbd> or <kbd>Backspace</kbd> should remove a widget or delete the content
557
+ * before or after a widget (depending on the content surrounding the widget).
558
+ */
559
+ _enableDeleteIntegration() {
560
+ const editor = this.editor;
561
+ const editingView = editor.editing.view;
562
+ const model = editor.model;
563
+ const schema = model.schema;
564
+ this._listenToIfEnabled(editingView.document, 'delete', (evt, domEventData) => {
565
+ // This event could be triggered from inside the widget but we are interested
566
+ // only when the widget is selected itself.
567
+ if (evt.eventPhase != 'atTarget') {
568
+ return;
569
+ }
570
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(model.document.selection);
571
+ // This listener handles only these cases when the fake caret is active.
572
+ if (!typeAroundFakeCaretPosition) {
573
+ return;
574
+ }
575
+ const direction = domEventData.direction;
576
+ const selectedModelWidget = model.document.selection.getSelectedElement();
577
+ const isFakeCaretBefore = typeAroundFakeCaretPosition === 'before';
578
+ const isDeleteForward = direction == 'forward';
579
+ const shouldDeleteEntireWidget = isFakeCaretBefore === isDeleteForward;
580
+ if (shouldDeleteEntireWidget) {
581
+ editor.execute('delete', {
582
+ selection: model.createSelection(selectedModelWidget, 'on')
583
+ });
584
+ }
585
+ else {
586
+ const range = schema.getNearestSelectionRange(model.createPositionAt(selectedModelWidget, typeAroundFakeCaretPosition), direction);
587
+ // If there is somewhere to move selection to, then there will be something to delete.
588
+ if (range) {
589
+ // If the range is NOT collapsed, then we know that the range contains an object (see getNearestSelectionRange() docs).
590
+ if (!range.isCollapsed) {
591
+ model.change(writer => {
592
+ writer.setSelection(range);
593
+ editor.execute(isDeleteForward ? 'deleteForward' : 'delete');
594
+ });
595
+ }
596
+ else {
597
+ const probe = model.createSelection(range.start);
598
+ model.modifySelection(probe, { direction });
599
+ // If the range is collapsed, let's see if a non-collapsed range exists that can could be deleted.
600
+ // If such range exists, use the editor command because it it safe for collaboration (it merges where it can).
601
+ if (!probe.focus.isEqual(range.start)) {
602
+ model.change(writer => {
603
+ writer.setSelection(range);
604
+ editor.execute(isDeleteForward ? 'deleteForward' : 'delete');
605
+ });
606
+ }
607
+ // If there is no non-collapsed range to be deleted then we are sure that there is an empty element
608
+ // next to a widget that should be removed. "delete" and "deleteForward" commands cannot get rid of it
609
+ // so calling Model#deleteContent here manually.
610
+ else {
611
+ const deepestEmptyRangeAncestor = getDeepestEmptyElementAncestor(schema, range.start.parent);
612
+ model.deleteContent(model.createSelection(deepestEmptyRangeAncestor, 'on'), {
613
+ doNotAutoparagraph: true
614
+ });
615
+ }
616
+ }
617
+ }
618
+ }
619
+ // If some content was deleted, don't let the handler from the Widget plugin kick in.
620
+ // If nothing was deleted, then the default handler will have nothing to do anyway.
621
+ domEventData.preventDefault();
622
+ evt.stop();
623
+ }, { context: isWidget });
624
+ }
625
+ /**
626
+ * Attaches the {@link module:engine/model/model~Model#event:insertContent} event listener that, for instance, allows the user to paste
627
+ * content near a widget when the fake caret is first activated using the arrow keys.
628
+ *
629
+ * The content is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
630
+ */
631
+ _enableInsertContentIntegration() {
632
+ const editor = this.editor;
633
+ const model = this.editor.model;
634
+ const documentSelection = model.document.selection;
635
+ this._listenToIfEnabled(editor.model, 'insertContent', (evt, [content, selectable]) => {
636
+ if (selectable && !selectable.is('documentSelection')) {
637
+ return;
638
+ }
639
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
640
+ if (!typeAroundFakeCaretPosition) {
641
+ return;
642
+ }
643
+ evt.stop();
644
+ return model.change(writer => {
645
+ const selectedElement = documentSelection.getSelectedElement();
646
+ const position = model.createPositionAt(selectedElement, typeAroundFakeCaretPosition);
647
+ const selection = writer.createSelection(position);
648
+ const result = model.insertContent(content, selection);
649
+ writer.setSelection(selection);
650
+ return result;
651
+ });
652
+ }, { priority: 'high' });
653
+ }
654
+ /**
655
+ * Attaches the {@link module:engine/model/model~Model#event:insertObject} event listener that modifies the
656
+ * `options.findOptimalPosition`parameter to position of fake caret in relation to selected element
657
+ * to reflect user's intent of desired insertion position.
658
+ *
659
+ * The object is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
660
+ */
661
+ _enableInsertObjectIntegration() {
662
+ const editor = this.editor;
663
+ const model = this.editor.model;
664
+ const documentSelection = model.document.selection;
665
+ this._listenToIfEnabled(editor.model, 'insertObject', (evt, args) => {
666
+ const [, selectable, options = {}] = args;
667
+ if (selectable && !selectable.is('documentSelection')) {
668
+ return;
669
+ }
670
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
671
+ if (!typeAroundFakeCaretPosition) {
672
+ return;
673
+ }
674
+ options.findOptimalPosition = typeAroundFakeCaretPosition;
675
+ args[3] = options;
676
+ }, { priority: 'high' });
677
+ }
678
+ /**
679
+ * Attaches the {@link module:engine/model/model~Model#event:deleteContent} event listener to block the event when the fake
680
+ * caret is active.
681
+ *
682
+ * This is required for cases that trigger {@link module:engine/model/model~Model#deleteContent `model.deleteContent()`}
683
+ * before calling {@link module:engine/model/model~Model#insertContent `model.insertContent()`} like, for instance,
684
+ * plain text pasting.
685
+ */
686
+ _enableDeleteContentIntegration() {
687
+ const editor = this.editor;
688
+ const model = this.editor.model;
689
+ const documentSelection = model.document.selection;
690
+ this._listenToIfEnabled(editor.model, 'deleteContent', (evt, [selection]) => {
691
+ if (selection && !selection.is('documentSelection')) {
692
+ return;
693
+ }
694
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
695
+ // Disable removing the selection content while pasting plain text.
696
+ if (typeAroundFakeCaretPosition) {
697
+ evt.stop();
698
+ }
699
+ }, { priority: 'high' });
700
+ }
701
+ }
702
+ /**
703
+ * Injects the type around UI into a view widget instance.
704
+ */
705
+ function injectUIIntoWidget(viewWriter, buttonTitles, widgetViewElement) {
706
+ const typeAroundWrapper = viewWriter.createUIElement('div', {
707
+ class: 'ck ck-reset_all ck-widget__type-around'
708
+ }, function (domDocument) {
709
+ const wrapperDomElement = this.toDomElement(domDocument);
710
+ injectButtons(wrapperDomElement, buttonTitles);
711
+ injectFakeCaret(wrapperDomElement);
712
+ return wrapperDomElement;
713
+ });
714
+ // Inject the type around wrapper into the widget's wrapper.
715
+ viewWriter.insert(viewWriter.createPositionAt(widgetViewElement, 'end'), typeAroundWrapper);
716
+ }
717
+ /**
718
+ * FYI: Not using the IconView class because each instance would need to be destroyed to avoid memory leaks
719
+ * and it's pretty hard to figure out when a view (widget) is gone for good so it's cheaper to use raw
720
+ * <svg> here.
721
+ */
722
+ function injectButtons(wrapperDomElement, buttonTitles) {
723
+ for (const position of POSSIBLE_INSERTION_POSITIONS) {
724
+ const buttonTemplate = new Template({
725
+ tag: 'div',
726
+ attributes: {
727
+ class: [
728
+ 'ck',
729
+ 'ck-widget__type-around__button',
730
+ `ck-widget__type-around__button_${position}`
731
+ ],
732
+ title: buttonTitles[position],
733
+ 'aria-hidden': 'true'
734
+ },
735
+ children: [
736
+ wrapperDomElement.ownerDocument.importNode(RETURN_ARROW_ICON_ELEMENT, true)
737
+ ]
738
+ });
739
+ wrapperDomElement.appendChild(buttonTemplate.render());
740
+ }
741
+ }
742
+ function injectFakeCaret(wrapperDomElement) {
743
+ const caretTemplate = new Template({
744
+ tag: 'div',
745
+ attributes: {
746
+ class: [
747
+ 'ck',
748
+ 'ck-widget__type-around__fake-caret'
749
+ ]
750
+ }
751
+ });
752
+ wrapperDomElement.appendChild(caretTemplate.render());
753
+ }
754
+ /**
755
+ * Returns the ancestor of an element closest to the root which is empty. For instance,
756
+ * for `<baz>`:
757
+ *
758
+ * ```
759
+ * <foo>abc<bar><baz></baz></bar></foo>
760
+ * ```
761
+ *
762
+ * it returns `<bar>`.
763
+ */
764
+ function getDeepestEmptyElementAncestor(schema, element) {
765
+ let deepestEmptyAncestor = element;
766
+ for (const ancestor of element.getAncestors({ parentFirst: true })) {
767
+ if (ancestor.childCount > 1 || schema.isLimit(ancestor)) {
768
+ break;
769
+ }
770
+ deepestEmptyAncestor = ancestor;
771
+ }
772
+ return deepestEmptyAncestor;
773
+ }