@ckeditor/ckeditor5-widget 41.3.1 → 41.4.0-alpha.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (125) hide show
  1. package/dist/index-content.css +4 -0
  2. package/dist/index-editor.css +144 -0
  3. package/dist/index.css +257 -0
  4. package/dist/index.css.map +1 -0
  5. package/dist/index.js +2877 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/translations/ar.d.ts +8 -0
  8. package/dist/translations/ar.js +5 -0
  9. package/dist/translations/az.d.ts +8 -0
  10. package/dist/translations/az.js +5 -0
  11. package/dist/translations/bg.d.ts +8 -0
  12. package/dist/translations/bg.js +5 -0
  13. package/dist/translations/bn.d.ts +8 -0
  14. package/dist/translations/bn.js +5 -0
  15. package/dist/translations/ca.d.ts +8 -0
  16. package/dist/translations/ca.js +5 -0
  17. package/dist/translations/cs.d.ts +8 -0
  18. package/dist/translations/cs.js +5 -0
  19. package/dist/translations/da.d.ts +8 -0
  20. package/dist/translations/da.js +5 -0
  21. package/dist/translations/de-ch.d.ts +8 -0
  22. package/dist/translations/de-ch.js +5 -0
  23. package/dist/translations/de.d.ts +8 -0
  24. package/dist/translations/de.js +5 -0
  25. package/dist/translations/el.d.ts +8 -0
  26. package/dist/translations/el.js +5 -0
  27. package/dist/translations/en-au.d.ts +8 -0
  28. package/dist/translations/en-au.js +5 -0
  29. package/dist/translations/en.d.ts +8 -0
  30. package/dist/translations/en.js +5 -0
  31. package/dist/translations/es.d.ts +8 -0
  32. package/dist/translations/es.js +5 -0
  33. package/dist/translations/et.d.ts +8 -0
  34. package/dist/translations/et.js +5 -0
  35. package/dist/translations/fa.d.ts +8 -0
  36. package/dist/translations/fa.js +5 -0
  37. package/dist/translations/fi.d.ts +8 -0
  38. package/dist/translations/fi.js +5 -0
  39. package/dist/translations/fr.d.ts +8 -0
  40. package/dist/translations/fr.js +5 -0
  41. package/dist/translations/gl.d.ts +8 -0
  42. package/dist/translations/gl.js +5 -0
  43. package/dist/translations/he.d.ts +8 -0
  44. package/dist/translations/he.js +5 -0
  45. package/dist/translations/hi.d.ts +8 -0
  46. package/dist/translations/hi.js +5 -0
  47. package/dist/translations/hr.d.ts +8 -0
  48. package/dist/translations/hr.js +5 -0
  49. package/dist/translations/hu.d.ts +8 -0
  50. package/dist/translations/hu.js +5 -0
  51. package/dist/translations/id.d.ts +8 -0
  52. package/dist/translations/id.js +5 -0
  53. package/dist/translations/it.d.ts +8 -0
  54. package/dist/translations/it.js +5 -0
  55. package/dist/translations/ja.d.ts +8 -0
  56. package/dist/translations/ja.js +5 -0
  57. package/dist/translations/ko.d.ts +8 -0
  58. package/dist/translations/ko.js +5 -0
  59. package/dist/translations/ku.d.ts +8 -0
  60. package/dist/translations/ku.js +5 -0
  61. package/dist/translations/lt.d.ts +8 -0
  62. package/dist/translations/lt.js +5 -0
  63. package/dist/translations/lv.d.ts +8 -0
  64. package/dist/translations/lv.js +5 -0
  65. package/dist/translations/ms.d.ts +8 -0
  66. package/dist/translations/ms.js +5 -0
  67. package/dist/translations/nl.d.ts +8 -0
  68. package/dist/translations/nl.js +5 -0
  69. package/dist/translations/no.d.ts +8 -0
  70. package/dist/translations/no.js +5 -0
  71. package/dist/translations/pl.d.ts +8 -0
  72. package/dist/translations/pl.js +5 -0
  73. package/dist/translations/pt-br.d.ts +8 -0
  74. package/dist/translations/pt-br.js +5 -0
  75. package/dist/translations/pt.d.ts +8 -0
  76. package/dist/translations/pt.js +5 -0
  77. package/dist/translations/ro.d.ts +8 -0
  78. package/dist/translations/ro.js +5 -0
  79. package/dist/translations/ru.d.ts +8 -0
  80. package/dist/translations/ru.js +5 -0
  81. package/dist/translations/sk.d.ts +8 -0
  82. package/dist/translations/sk.js +5 -0
  83. package/dist/translations/sq.d.ts +8 -0
  84. package/dist/translations/sq.js +5 -0
  85. package/dist/translations/sr-latn.d.ts +8 -0
  86. package/dist/translations/sr-latn.js +5 -0
  87. package/dist/translations/sr.d.ts +8 -0
  88. package/dist/translations/sr.js +5 -0
  89. package/dist/translations/sv.d.ts +8 -0
  90. package/dist/translations/sv.js +5 -0
  91. package/dist/translations/th.d.ts +8 -0
  92. package/dist/translations/th.js +5 -0
  93. package/dist/translations/tk.d.ts +8 -0
  94. package/dist/translations/tk.js +5 -0
  95. package/dist/translations/tr.d.ts +8 -0
  96. package/dist/translations/tr.js +5 -0
  97. package/dist/translations/uk.d.ts +8 -0
  98. package/dist/translations/uk.js +5 -0
  99. package/dist/translations/ur.d.ts +8 -0
  100. package/dist/translations/ur.js +5 -0
  101. package/dist/translations/uz.d.ts +8 -0
  102. package/dist/translations/uz.js +5 -0
  103. package/dist/translations/vi.d.ts +8 -0
  104. package/dist/translations/vi.js +5 -0
  105. package/dist/translations/zh-cn.d.ts +8 -0
  106. package/dist/translations/zh-cn.js +5 -0
  107. package/dist/translations/zh.d.ts +8 -0
  108. package/dist/translations/zh.js +5 -0
  109. package/dist/types/augmentation.d.ts +17 -0
  110. package/dist/types/highlightstack.d.ts +78 -0
  111. package/dist/types/index.d.ts +17 -0
  112. package/dist/types/utils.d.ts +219 -0
  113. package/dist/types/verticalnavigation.d.ts +19 -0
  114. package/dist/types/widget.d.ts +107 -0
  115. package/dist/types/widgetresize/resizer.d.ts +181 -0
  116. package/dist/types/widgetresize/resizerstate.d.ts +129 -0
  117. package/dist/types/widgetresize/sizeview.d.ts +59 -0
  118. package/dist/types/widgetresize.d.ts +129 -0
  119. package/dist/types/widgettoolbarrepository.d.ts +98 -0
  120. package/dist/types/widgettypearound/utils.d.ts +42 -0
  121. package/dist/types/widgettypearound/widgettypearound.d.ts +233 -0
  122. package/package.json +8 -7
  123. package/src/utils.d.ts +18 -1
  124. package/src/utils.js +45 -1
  125. package/src/widgetresize/resizerstate.js +2 -23
package/dist/index.js ADDED
@@ -0,0 +1,2877 @@
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 { Plugin } from '@ckeditor/ckeditor5-core/dist/index.js';
6
+ import { MouseObserver, TreeWalker } from '@ckeditor/ckeditor5-engine/dist/index.js';
7
+ import { Delete } from '@ckeditor/ckeditor5-typing/dist/index.js';
8
+ import { EmitterMixin, CKEditorError, Rect, toArray, isForwardArrowKeyCode, env, keyCodes, getLocalizedArrowKeyCodeDirection, logWarning, ObservableMixin, compareArrays, global, DomEmitterMixin } from '@ckeditor/ckeditor5-utils/dist/index.js';
9
+ import { IconView, Template, ContextualBalloon, ToolbarView, BalloonPanelView, View } from '@ckeditor/ckeditor5-ui/dist/index.js';
10
+ import { Enter } from '@ckeditor/ckeditor5-enter/dist/index.js';
11
+ import { throttle } from 'lodash-es';
12
+
13
+ class HighlightStack extends EmitterMixin() {
14
+ /**
15
+ * Adds highlight descriptor to the stack.
16
+ *
17
+ * @fires change:top
18
+ */ add(descriptor, writer) {
19
+ const stack = this._stack;
20
+ // Save top descriptor and insert new one. If top is changed - fire event.
21
+ const oldTop = stack[0];
22
+ this._insertDescriptor(descriptor);
23
+ const newTop = stack[0];
24
+ // When new object is at the top and stores different information.
25
+ if (oldTop !== newTop && !compareDescriptors(oldTop, newTop)) {
26
+ this.fire('change:top', {
27
+ oldDescriptor: oldTop,
28
+ newDescriptor: newTop,
29
+ writer
30
+ });
31
+ }
32
+ }
33
+ /**
34
+ * Removes highlight descriptor from the stack.
35
+ *
36
+ * @fires change:top
37
+ * @param id Id of the descriptor to remove.
38
+ */ remove(id, writer) {
39
+ const stack = this._stack;
40
+ const oldTop = stack[0];
41
+ this._removeDescriptor(id);
42
+ const newTop = stack[0];
43
+ // When new object is at the top and stores different information.
44
+ if (oldTop !== newTop && !compareDescriptors(oldTop, newTop)) {
45
+ this.fire('change:top', {
46
+ oldDescriptor: oldTop,
47
+ newDescriptor: newTop,
48
+ writer
49
+ });
50
+ }
51
+ }
52
+ /**
53
+ * Inserts a given descriptor in correct place in the stack. It also takes care about updating information
54
+ * when descriptor with same id is already present.
55
+ */ _insertDescriptor(descriptor) {
56
+ const stack = this._stack;
57
+ const index = stack.findIndex((item)=>item.id === descriptor.id);
58
+ // Inserting exact same descriptor - do nothing.
59
+ if (compareDescriptors(descriptor, stack[index])) {
60
+ return;
61
+ }
62
+ // If descriptor with same id but with different information is on the stack - remove it.
63
+ if (index > -1) {
64
+ stack.splice(index, 1);
65
+ }
66
+ // Find correct place to insert descriptor in the stack.
67
+ // It has different information (for example priority) so it must be re-inserted in correct place.
68
+ let i = 0;
69
+ while(stack[i] && shouldABeBeforeB(stack[i], descriptor)){
70
+ i++;
71
+ }
72
+ stack.splice(i, 0, descriptor);
73
+ }
74
+ /**
75
+ * Removes descriptor with given id from the stack.
76
+ *
77
+ * @param id Descriptor's id.
78
+ */ _removeDescriptor(id) {
79
+ const stack = this._stack;
80
+ const index = stack.findIndex((item)=>item.id === id);
81
+ // If descriptor with same id is on the list - remove it.
82
+ if (index > -1) {
83
+ stack.splice(index, 1);
84
+ }
85
+ }
86
+ constructor(){
87
+ super(...arguments);
88
+ this._stack = [];
89
+ }
90
+ }
91
+ /**
92
+ * Compares two descriptors by checking their priority and class list.
93
+ *
94
+ * @returns Returns true if both descriptors are defined and have same priority and classes.
95
+ */ function compareDescriptors(a, b) {
96
+ return a && b && a.priority == b.priority && classesToString(a.classes) == classesToString(b.classes);
97
+ }
98
+ /**
99
+ * Checks whenever first descriptor should be placed in the stack before second one.
100
+ */ function shouldABeBeforeB(a, b) {
101
+ if (a.priority > b.priority) {
102
+ return true;
103
+ } else if (a.priority < b.priority) {
104
+ return false;
105
+ }
106
+ // When priorities are equal and names are different - use classes to compare.
107
+ return classesToString(a.classes) > classesToString(b.classes);
108
+ }
109
+ /**
110
+ * Converts CSS classes passed with {@link module:engine/conversion/downcasthelpers~HighlightDescriptor} to
111
+ * sorted string.
112
+ */ function classesToString(classes) {
113
+ return Array.isArray(classes) ? classes.sort().join(',') : classes;
114
+ }
115
+
116
+ var dragHandleIcon = "<svg viewBox=\"0 0 16 16\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M4 0v1H1v3H0V.5A.5.5 0 0 1 .5 0H4zm8 0h3.5a.5.5 0 0 1 .5.5V4h-1V1h-3V0zM4 16H.5a.5.5 0 0 1-.5-.5V12h1v3h3v1zm8 0v-1h3v-3h1v3.5a.5.5 0 0 1-.5.5H12z\"/><path fill-opacity=\".256\" d=\"M1 1h14v14H1z\"/><g class=\"ck-icon__selected-indicator\"><path d=\"M7 0h2v1H7V0zM0 7h1v2H0V7zm15 0h1v2h-1V7zm-8 8h2v1H7v-1z\"/><path fill-opacity=\".254\" d=\"M1 1h14v14H1z\"/></g></svg>";
117
+
118
+ /**
119
+ * CSS class added to each widget element.
120
+ */ const WIDGET_CLASS_NAME = 'ck-widget';
121
+ /**
122
+ * CSS class added to currently selected widget element.
123
+ */ const WIDGET_SELECTED_CLASS_NAME = 'ck-widget_selected';
124
+ /**
125
+ * Returns `true` if given {@link module:engine/view/node~Node} is an {@link module:engine/view/element~Element} and a widget.
126
+ */ function isWidget(node) {
127
+ if (!node.is('element')) {
128
+ return false;
129
+ }
130
+ return !!node.getCustomProperty('widget');
131
+ }
132
+ /**
133
+ * Converts the given {@link module:engine/view/element~Element} to a widget in the following way:
134
+ *
135
+ * * sets the `contenteditable` attribute to `"false"`,
136
+ * * adds the `ck-widget` CSS class,
137
+ * * adds a custom {@link module:engine/view/element~Element#getFillerOffset `getFillerOffset()`} method returning `null`,
138
+ * * adds a custom property allowing to recognize widget elements by using {@link ~isWidget `isWidget()`},
139
+ * * implements the {@link ~setHighlightHandling view highlight on widgets}.
140
+ *
141
+ * This function needs to be used in conjunction with
142
+ * {@link module:engine/conversion/downcasthelpers~DowncastHelpers downcast conversion helpers}
143
+ * like {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`}.
144
+ * Moreover, typically you will want to use `toWidget()` only for `editingDowncast`, while keeping the `dataDowncast` clean.
145
+ *
146
+ * For example, in order to convert a `<widget>` model element to `<div class="widget">` in the view, you can define
147
+ * such converters:
148
+ *
149
+ * ```ts
150
+ * editor.conversion.for( 'editingDowncast' )
151
+ * .elementToElement( {
152
+ * model: 'widget',
153
+ * view: ( modelItem, { writer } ) => {
154
+ * const div = writer.createContainerElement( 'div', { class: 'widget' } );
155
+ *
156
+ * return toWidget( div, writer, { label: 'some widget' } );
157
+ * }
158
+ * } );
159
+ *
160
+ * editor.conversion.for( 'dataDowncast' )
161
+ * .elementToElement( {
162
+ * model: 'widget',
163
+ * view: ( modelItem, { writer } ) => {
164
+ * return writer.createContainerElement( 'div', { class: 'widget' } );
165
+ * }
166
+ * } );
167
+ * ```
168
+ *
169
+ * See the full source code of the widget (with a nested editable) schema definition and converters in
170
+ * [this sample](https://github.com/ckeditor/ckeditor5-widget/blob/master/tests/manual/widget-with-nestededitable.js).
171
+ *
172
+ * @param options Additional options.
173
+ * @param options.label Element's label provided to the {@link ~setLabel} function. It can be passed as
174
+ * a plain string or a function returning a string. It represents the widget for assistive technologies (like screen readers).
175
+ * @param options.hasSelectionHandle If `true`, the widget will have a selection handle added.
176
+ * @returns Returns the same element.
177
+ */ function toWidget(element, writer, options = {}) {
178
+ if (!element.is('containerElement')) {
179
+ /**
180
+ * The element passed to `toWidget()` must be a {@link module:engine/view/containerelement~ContainerElement}
181
+ * instance.
182
+ *
183
+ * @error widget-to-widget-wrong-element-type
184
+ * @param element The view element passed to `toWidget()`.
185
+ */ throw new CKEditorError('widget-to-widget-wrong-element-type', null, {
186
+ element
187
+ });
188
+ }
189
+ writer.setAttribute('contenteditable', 'false', element);
190
+ writer.addClass(WIDGET_CLASS_NAME, element);
191
+ writer.setCustomProperty('widget', true, element);
192
+ element.getFillerOffset = getFillerOffset;
193
+ writer.setCustomProperty('widgetLabel', [], element);
194
+ if (options.label) {
195
+ setLabel(element, options.label);
196
+ }
197
+ if (options.hasSelectionHandle) {
198
+ addSelectionHandle(element, writer);
199
+ }
200
+ setHighlightHandling(element, writer);
201
+ return element;
202
+ }
203
+ /**
204
+ * Default handler for adding a highlight on a widget.
205
+ * It adds CSS class and attributes basing on the given highlight descriptor.
206
+ */ function addHighlight(element, descriptor, writer) {
207
+ if (descriptor.classes) {
208
+ writer.addClass(toArray(descriptor.classes), element);
209
+ }
210
+ if (descriptor.attributes) {
211
+ for(const key in descriptor.attributes){
212
+ writer.setAttribute(key, descriptor.attributes[key], element);
213
+ }
214
+ }
215
+ }
216
+ /**
217
+ * Default handler for removing a highlight from a widget.
218
+ * It removes CSS class and attributes basing on the given highlight descriptor.
219
+ */ function removeHighlight(element, descriptor, writer) {
220
+ if (descriptor.classes) {
221
+ writer.removeClass(toArray(descriptor.classes), element);
222
+ }
223
+ if (descriptor.attributes) {
224
+ for(const key in descriptor.attributes){
225
+ writer.removeAttribute(key, element);
226
+ }
227
+ }
228
+ }
229
+ /**
230
+ * Sets highlight handling methods. Uses {@link module:widget/highlightstack~HighlightStack} to
231
+ * properly determine which highlight descriptor should be used at given time.
232
+ */ function setHighlightHandling(element, writer, add = addHighlight, remove = removeHighlight) {
233
+ const stack = new HighlightStack();
234
+ stack.on('change:top', (evt, data)=>{
235
+ if (data.oldDescriptor) {
236
+ remove(element, data.oldDescriptor, data.writer);
237
+ }
238
+ if (data.newDescriptor) {
239
+ add(element, data.newDescriptor, data.writer);
240
+ }
241
+ });
242
+ const addHighlightCallback = (element, descriptor, writer)=>stack.add(descriptor, writer);
243
+ const removeHighlightCallback = (element, id, writer)=>stack.remove(id, writer);
244
+ writer.setCustomProperty('addHighlight', addHighlightCallback, element);
245
+ writer.setCustomProperty('removeHighlight', removeHighlightCallback, element);
246
+ }
247
+ /**
248
+ * Sets label for given element.
249
+ * It can be passed as a plain string or a function returning a string. Function will be called each time label is retrieved by
250
+ * {@link ~getLabel `getLabel()`}.
251
+ */ function setLabel(element, labelOrCreator) {
252
+ const widgetLabel = element.getCustomProperty('widgetLabel');
253
+ widgetLabel.push(labelOrCreator);
254
+ }
255
+ /**
256
+ * Returns the label of the provided element.
257
+ */ function getLabel(element) {
258
+ const widgetLabel = element.getCustomProperty('widgetLabel');
259
+ return widgetLabel.reduce((prev, current)=>{
260
+ if (typeof current === 'function') {
261
+ return prev ? prev + '. ' + current() : current();
262
+ } else {
263
+ return prev ? prev + '. ' + current : current;
264
+ }
265
+ }, '');
266
+ }
267
+ /**
268
+ * Adds functionality to the provided {@link module:engine/view/editableelement~EditableElement} to act as a widget's editable:
269
+ *
270
+ * * sets the `contenteditable` attribute to `true` when {@link module:engine/view/editableelement~EditableElement#isReadOnly} is `false`,
271
+ * otherwise sets it to `false`,
272
+ * * adds the `ck-editor__editable` and `ck-editor__nested-editable` CSS classes,
273
+ * * adds the `ck-editor__nested-editable_focused` CSS class when the editable is focused and removes it when it is blurred.
274
+ * * implements the {@link ~setHighlightHandling view highlight on widget's editable}.
275
+ *
276
+ * Similarly to {@link ~toWidget `toWidget()`} this function should be used in `editingDowncast` only and it is usually
277
+ * used together with {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`}.
278
+ *
279
+ * For example, in order to convert a `<nested>` model element to `<div class="nested">` in the view, you can define
280
+ * such converters:
281
+ *
282
+ * ```ts
283
+ * editor.conversion.for( 'editingDowncast' )
284
+ * .elementToElement( {
285
+ * model: 'nested',
286
+ * view: ( modelItem, { writer } ) => {
287
+ * const div = writer.createEditableElement( 'div', { class: 'nested' } );
288
+ *
289
+ * return toWidgetEditable( nested, writer, { label: 'label for editable' } );
290
+ * }
291
+ * } );
292
+ *
293
+ * editor.conversion.for( 'dataDowncast' )
294
+ * .elementToElement( {
295
+ * model: 'nested',
296
+ * view: ( modelItem, { writer } ) => {
297
+ * return writer.createContainerElement( 'div', { class: 'nested' } );
298
+ * }
299
+ * } );
300
+ * ```
301
+ *
302
+ * See the full source code of the widget (with nested editable) schema definition and converters in
303
+ * [this sample](https://github.com/ckeditor/ckeditor5-widget/blob/master/tests/manual/widget-with-nestededitable.js).
304
+ *
305
+ * @param options Additional options.
306
+ * @param options.label Editable's label used by assistive technologies (e.g. screen readers).
307
+ * @returns Returns the same element that was provided in the `editable` parameter
308
+ */ function toWidgetEditable(editable, writer, options = {}) {
309
+ writer.addClass([
310
+ 'ck-editor__editable',
311
+ 'ck-editor__nested-editable'
312
+ ], editable);
313
+ writer.setAttribute('role', 'textbox', editable);
314
+ writer.setAttribute('tabindex', '-1', editable);
315
+ if (options.label) {
316
+ writer.setAttribute('aria-label', options.label, editable);
317
+ }
318
+ // Set initial contenteditable value.
319
+ writer.setAttribute('contenteditable', editable.isReadOnly ? 'false' : 'true', editable);
320
+ // Bind the contenteditable property to element#isReadOnly.
321
+ editable.on('change:isReadOnly', (evt, property, is)=>{
322
+ writer.setAttribute('contenteditable', is ? 'false' : 'true', editable);
323
+ });
324
+ editable.on('change:isFocused', (evt, property, is)=>{
325
+ if (is) {
326
+ writer.addClass('ck-editor__nested-editable_focused', editable);
327
+ } else {
328
+ writer.removeClass('ck-editor__nested-editable_focused', editable);
329
+ }
330
+ });
331
+ setHighlightHandling(editable, writer);
332
+ return editable;
333
+ }
334
+ /**
335
+ * Returns a model range which is optimal (in terms of UX) for inserting a widget block.
336
+ *
337
+ * For instance, if a selection is in the middle of a paragraph, the collapsed range before this paragraph
338
+ * will be returned so that it is not split. If the selection is at the end of a paragraph,
339
+ * the collapsed range after this paragraph will be returned.
340
+ *
341
+ * Note: If the selection is placed in an empty block, the range in that block will be returned. If that range
342
+ * is then passed to {@link module:engine/model/model~Model#insertContent}, the block will be fully replaced
343
+ * by the inserted widget block.
344
+ *
345
+ * @param selection The selection based on which the insertion position should be calculated.
346
+ * @param model Model instance.
347
+ * @returns The optimal range.
348
+ */ function findOptimalInsertionRange(selection, model) {
349
+ const selectedElement = selection.getSelectedElement();
350
+ if (selectedElement) {
351
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(selection);
352
+ // If the WidgetTypeAround "fake caret" is displayed, use its position for the insertion
353
+ // to provide the most predictable UX (https://github.com/ckeditor/ckeditor5/issues/7438).
354
+ if (typeAroundFakeCaretPosition) {
355
+ return model.createRange(model.createPositionAt(selectedElement, typeAroundFakeCaretPosition));
356
+ }
357
+ }
358
+ return model.schema.findOptimalInsertionRange(selection);
359
+ }
360
+ /**
361
+ * A util to be used in order to map view positions to correct model positions when implementing a widget
362
+ * which renders non-empty view element for an empty model element.
363
+ *
364
+ * For example:
365
+ *
366
+ * ```
367
+ * // Model:
368
+ * <placeholder type="name"></placeholder>
369
+ *
370
+ * // View:
371
+ * <span class="placeholder">name</span>
372
+ * ```
373
+ *
374
+ * In such case, view positions inside `<span>` cannot be correctly mapped to the model (because the model element is empty).
375
+ * To handle mapping positions inside `<span class="placeholder">` to the model use this util as follows:
376
+ *
377
+ * ```ts
378
+ * editor.editing.mapper.on(
379
+ * 'viewToModelPosition',
380
+ * viewToModelPositionOutsideModelElement( model, viewElement => viewElement.hasClass( 'placeholder' ) )
381
+ * );
382
+ * ```
383
+ *
384
+ * The callback will try to map the view offset of selection to an expected model position.
385
+ *
386
+ * 1. When the position is at the end (or in the middle) of the inline widget:
387
+ *
388
+ * ```
389
+ * // View:
390
+ * <p>foo <span class="placeholder">name|</span> bar</p>
391
+ *
392
+ * // Model:
393
+ * <paragraph>foo <placeholder type="name"></placeholder>| bar</paragraph>
394
+ * ```
395
+ *
396
+ * 2. When the position is at the beginning of the inline widget:
397
+ *
398
+ * ```
399
+ * // View:
400
+ * <p>foo <span class="placeholder">|name</span> bar</p>
401
+ *
402
+ * // Model:
403
+ * <paragraph>foo |<placeholder type="name"></placeholder> bar</paragraph>
404
+ * ```
405
+ *
406
+ * @param model Model instance on which the callback operates.
407
+ * @param viewElementMatcher Function that is passed a view element and should return `true` if the custom mapping
408
+ * should be applied to the given view element.
409
+ */ function viewToModelPositionOutsideModelElement(model, viewElementMatcher) {
410
+ return (evt, data)=>{
411
+ const { mapper, viewPosition } = data;
412
+ const viewParent = mapper.findMappedViewAncestor(viewPosition);
413
+ if (!viewElementMatcher(viewParent)) {
414
+ return;
415
+ }
416
+ const modelParent = mapper.toModelElement(viewParent);
417
+ data.modelPosition = model.createPositionAt(modelParent, viewPosition.isAtStart ? 'before' : 'after');
418
+ };
419
+ }
420
+ /**
421
+ * Default filler offset function applied to all widget elements.
422
+ */ function getFillerOffset() {
423
+ return null;
424
+ }
425
+ /**
426
+ * Adds a drag handle to the widget.
427
+ */ function addSelectionHandle(widgetElement, writer) {
428
+ const selectionHandle = writer.createUIElement('div', {
429
+ class: 'ck ck-widget__selection-handle'
430
+ }, function(domDocument) {
431
+ const domElement = this.toDomElement(domDocument);
432
+ // Use the IconView from the ui library.
433
+ const icon = new IconView();
434
+ icon.set('content', dragHandleIcon);
435
+ // Render the icon view right away to append its #element to the selectionHandle DOM element.
436
+ icon.render();
437
+ domElement.appendChild(icon.element);
438
+ return domElement;
439
+ });
440
+ // Append the selection handle into the widget wrapper.
441
+ writer.insert(writer.createPositionAt(widgetElement, 0), selectionHandle);
442
+ writer.addClass([
443
+ 'ck-widget_with-selection-handle'
444
+ ], widgetElement);
445
+ }
446
+ /**
447
+ * Starting from a DOM resize host element (an element that receives dimensions as a result of resizing),
448
+ * this helper returns the width of the found ancestor element.
449
+ *
450
+ * **Note**: This helper searches up to 5 levels of ancestors only.
451
+ *
452
+ * @param domResizeHost Resize host DOM element that receives dimensions as a result of resizing.
453
+ * @returns Width of ancestor element in pixels or 0 if no ancestor with a computed width has been found.
454
+ */ function calculateResizeHostAncestorWidth(domResizeHost) {
455
+ const domResizeHostParent = domResizeHost.parentElement;
456
+ if (!domResizeHostParent) {
457
+ return 0;
458
+ }
459
+ // Need to use computed style as it properly excludes parent's paddings from the returned value.
460
+ let parentWidth = parseFloat(domResizeHostParent.ownerDocument.defaultView.getComputedStyle(domResizeHostParent).width);
461
+ // Sometimes parent width cannot be accessed. If that happens we should go up in the elements tree
462
+ // and try to get width from next ancestor.
463
+ // https://github.com/ckeditor/ckeditor5/issues/10776
464
+ const ancestorLevelLimit = 5;
465
+ let currentLevel = 0;
466
+ let checkedElement = domResizeHostParent;
467
+ while(isNaN(parentWidth)){
468
+ checkedElement = checkedElement.parentElement;
469
+ if (++currentLevel > ancestorLevelLimit) {
470
+ return 0;
471
+ }
472
+ parentWidth = parseFloat(domResizeHostParent.ownerDocument.defaultView.getComputedStyle(checkedElement).width);
473
+ }
474
+ return parentWidth;
475
+ }
476
+ /**
477
+ * Calculates a relative width of a `domResizeHost` compared to its ancestor in percents.
478
+ *
479
+ * @param domResizeHost Resize host DOM element.
480
+ * @returns Percentage value between 0 and 100.
481
+ */ function calculateResizeHostPercentageWidth(domResizeHost, resizeHostRect = new Rect(domResizeHost)) {
482
+ const parentWidth = calculateResizeHostAncestorWidth(domResizeHost);
483
+ if (!parentWidth) {
484
+ return 0;
485
+ }
486
+ return resizeHostRect.width / parentWidth * 100;
487
+ }
488
+
489
+ /**
490
+ * The name of the type around model selection attribute responsible for
491
+ * displaying a fake caret next to a selected widget.
492
+ */ const TYPE_AROUND_SELECTION_ATTRIBUTE = 'widget-type-around';
493
+ /**
494
+ * Checks if an element is a widget that qualifies to get the widget type around UI.
495
+ */ function isTypeAroundWidget(viewElement, modelElement, schema) {
496
+ return !!viewElement && isWidget(viewElement) && !schema.isInline(modelElement);
497
+ }
498
+ /**
499
+ * For the passed HTML element, this helper finds the closest widget type around button ancestor.
500
+ */ function getClosestTypeAroundDomButton(domElement) {
501
+ return domElement.closest('.ck-widget__type-around__button');
502
+ }
503
+ /**
504
+ * For the passed widget type around button element, this helper determines at which position
505
+ * the paragraph would be inserted into the content if, for instance, the button was
506
+ * clicked by the user.
507
+ *
508
+ * @returns The position of the button.
509
+ */ function getTypeAroundButtonPosition(domElement) {
510
+ return domElement.classList.contains('ck-widget__type-around__button_before') ? 'before' : 'after';
511
+ }
512
+ /**
513
+ * For the passed HTML element, this helper returns the closest view widget ancestor.
514
+ */ function getClosestWidgetViewElement(domElement, domConverter) {
515
+ const widgetDomElement = domElement.closest('.ck-widget');
516
+ return domConverter.mapDomToView(widgetDomElement);
517
+ }
518
+ /**
519
+ * For the passed selection instance, it returns the position of the fake caret displayed next to a widget.
520
+ *
521
+ * **Note**: If the fake caret is not currently displayed, `null` is returned.
522
+ *
523
+ * @returns The position of the fake caret or `null` when none is present.
524
+ */ function getTypeAroundFakeCaretPosition(selection) {
525
+ return selection.getAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
526
+ }
527
+
528
+ var returnIcon = "<svg viewBox=\"0 0 10 8\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M9.055.263v3.972h-6.77M1 4.216l2-2.038m-2 2 2 2.038\"/></svg>";
529
+
530
+ const POSSIBLE_INSERTION_POSITIONS = [
531
+ 'before',
532
+ 'after'
533
+ ];
534
+ // Do the SVG parsing once and then clone the result <svg> DOM element for each new button.
535
+ const RETURN_ARROW_ICON_ELEMENT = new DOMParser().parseFromString(returnIcon, 'image/svg+xml').firstChild;
536
+ const PLUGIN_DISABLED_EDITING_ROOT_CLASS = 'ck-widget__type-around_disabled';
537
+ class WidgetTypeAround extends Plugin {
538
+ /**
539
+ * @inheritDoc
540
+ */ static get pluginName() {
541
+ return 'WidgetTypeAround';
542
+ }
543
+ /**
544
+ * @inheritDoc
545
+ */ static get requires() {
546
+ return [
547
+ Enter,
548
+ Delete
549
+ ];
550
+ }
551
+ /**
552
+ * @inheritDoc
553
+ */ init() {
554
+ const editor = this.editor;
555
+ const editingView = editor.editing.view;
556
+ // Set a CSS class on the view editing root when the plugin is disabled so all the buttons
557
+ // and lines visually disappear. All the interactions are disabled in individual plugin methods.
558
+ this.on('change:isEnabled', (evt, data, isEnabled)=>{
559
+ editingView.change((writer)=>{
560
+ for (const root of editingView.document.roots){
561
+ if (isEnabled) {
562
+ writer.removeClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
563
+ } else {
564
+ writer.addClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
565
+ }
566
+ }
567
+ });
568
+ if (!isEnabled) {
569
+ editor.model.change((writer)=>{
570
+ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
571
+ });
572
+ }
573
+ });
574
+ this._enableTypeAroundUIInjection();
575
+ this._enableInsertingParagraphsOnButtonClick();
576
+ this._enableInsertingParagraphsOnEnterKeypress();
577
+ this._enableInsertingParagraphsOnTypingKeystroke();
578
+ this._enableTypeAroundFakeCaretActivationUsingKeyboardArrows();
579
+ this._enableDeleteIntegration();
580
+ this._enableInsertContentIntegration();
581
+ this._enableInsertObjectIntegration();
582
+ this._enableDeleteContentIntegration();
583
+ }
584
+ /**
585
+ * @inheritDoc
586
+ */ destroy() {
587
+ super.destroy();
588
+ this._currentFakeCaretModelElement = null;
589
+ }
590
+ /**
591
+ * Inserts a new paragraph next to a widget element with the selection anchored in it.
592
+ *
593
+ * **Note**: This method is heavily user-oriented and will both focus the editing view and scroll
594
+ * the viewport to the selection in the inserted paragraph.
595
+ *
596
+ * @param widgetModelElement The model widget element next to which a paragraph is inserted.
597
+ * @param position The position where the paragraph is inserted. Either `'before'` or `'after'` the widget.
598
+ */ _insertParagraph(widgetModelElement, position) {
599
+ const editor = this.editor;
600
+ const editingView = editor.editing.view;
601
+ const attributesToCopy = editor.model.schema.getAttributesWithProperty(widgetModelElement, 'copyOnReplace', true);
602
+ editor.execute('insertParagraph', {
603
+ position: editor.model.createPositionAt(widgetModelElement, position),
604
+ attributes: attributesToCopy
605
+ });
606
+ editingView.focus();
607
+ editingView.scrollToTheSelection();
608
+ }
609
+ /**
610
+ * A wrapper for the {@link module:utils/emittermixin~Emitter#listenTo} method that executes the callbacks only
611
+ * when the plugin {@link #isEnabled is enabled}.
612
+ *
613
+ * @param emitter The object that fires the event.
614
+ * @param event The name of the event.
615
+ * @param callback The function to be called on event.
616
+ * @param options Additional options.
617
+ * @param options.priority The priority of this event callback. The higher the priority value the sooner
618
+ * the callback will be fired. Events having the same priority are called in the order they were added.
619
+ */ _listenToIfEnabled(emitter, event, callback, options) {
620
+ this.listenTo(emitter, event, (...args)=>{
621
+ // Do not respond if the plugin is disabled.
622
+ if (this.isEnabled) {
623
+ callback(...args);
624
+ }
625
+ }, options);
626
+ }
627
+ /**
628
+ * Similar to {@link #_insertParagraph}, this method inserts a paragraph except that it
629
+ * does not expect a position. Instead, it performs the insertion next to a selected widget
630
+ * according to the `widget-type-around` model selection attribute value (fake caret position).
631
+ *
632
+ * Because this method requires the `widget-type-around` attribute to be set,
633
+ * the insertion can only happen when the widget's fake caret is active (e.g. activated
634
+ * using the keyboard).
635
+ *
636
+ * @returns Returns `true` when the paragraph was inserted (the attribute was present) and `false` otherwise.
637
+ */ _insertParagraphAccordingToFakeCaretPosition() {
638
+ const editor = this.editor;
639
+ const model = editor.model;
640
+ const modelSelection = model.document.selection;
641
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection);
642
+ if (!typeAroundFakeCaretPosition) {
643
+ return false;
644
+ }
645
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
646
+ // @if CK_DEBUG_TYPING // console.info( '%c[WidgetTypeAround]%c Fake caret -> insert paragraph',
647
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green', ''
648
+ // @if CK_DEBUG_TYPING // );
649
+ // @if CK_DEBUG_TYPING // }
650
+ const selectedModelElement = modelSelection.getSelectedElement();
651
+ this._insertParagraph(selectedModelElement, typeAroundFakeCaretPosition);
652
+ return true;
653
+ }
654
+ /**
655
+ * Creates a listener in the editing conversion pipeline that injects the widget type around
656
+ * UI into every single widget instance created in the editor.
657
+ *
658
+ * The UI is delivered as a {@link module:engine/view/uielement~UIElement}
659
+ * wrapper which renders DOM buttons that users can use to insert paragraphs.
660
+ */ _enableTypeAroundUIInjection() {
661
+ const editor = this.editor;
662
+ const schema = editor.model.schema;
663
+ const t = editor.locale.t;
664
+ const buttonTitles = {
665
+ before: t('Insert paragraph before block'),
666
+ after: t('Insert paragraph after block')
667
+ };
668
+ editor.editing.downcastDispatcher.on('insert', (evt, data, conversionApi)=>{
669
+ const viewElement = conversionApi.mapper.toViewElement(data.item);
670
+ if (!viewElement) {
671
+ return;
672
+ }
673
+ // Filter out non-widgets and inline widgets.
674
+ if (isTypeAroundWidget(viewElement, data.item, schema)) {
675
+ injectUIIntoWidget(conversionApi.writer, buttonTitles, viewElement);
676
+ const widgetLabel = viewElement.getCustomProperty('widgetLabel');
677
+ widgetLabel.push(()=>{
678
+ return this.isEnabled ? t('Press Enter to type after or press Shift + Enter to type before the widget') : '';
679
+ });
680
+ }
681
+ }, {
682
+ priority: 'low'
683
+ });
684
+ }
685
+ /**
686
+ * Brings support for the fake caret that appears when either:
687
+ *
688
+ * * the selection moves to a widget from a position next to it using arrow keys,
689
+ * * the arrow key is pressed when the widget is already selected.
690
+ *
691
+ * The fake caret lets the user know that they can start typing or just press
692
+ * <kbd>Enter</kbd> to insert a paragraph at the position next to a widget as suggested by the fake caret.
693
+ *
694
+ * The fake caret disappears when the user changes the selection or the editor
695
+ * gets blurred.
696
+ *
697
+ * The whole idea is as follows:
698
+ *
699
+ * 1. A user does one of the 2 scenarios described at the beginning.
700
+ * 2. The "keydown" listener is executed and the decision is made whether to show or hide the fake caret.
701
+ * 3. If it should show up, the `widget-type-around` model selection attribute is set indicating
702
+ * on which side of the widget it should appear.
703
+ * 4. The selection dispatcher reacts to the selection attribute and sets CSS classes responsible for the
704
+ * fake caret on the view widget.
705
+ * 5. If the fake caret should disappear, the selection attribute is removed and the dispatcher
706
+ * does the CSS class clean-up in the view.
707
+ * 6. Additionally, `change:range` and `FocusTracker#isFocused` listeners also remove the selection
708
+ * attribute (the former also removes widget CSS classes).
709
+ */ _enableTypeAroundFakeCaretActivationUsingKeyboardArrows() {
710
+ const editor = this.editor;
711
+ const model = editor.model;
712
+ const modelSelection = model.document.selection;
713
+ const schema = model.schema;
714
+ const editingView = editor.editing.view;
715
+ // This is the main listener responsible for the fake caret.
716
+ // Note: The priority must precede the default Widget class keydown handler ("high").
717
+ this._listenToIfEnabled(editingView.document, 'arrowKey', (evt, domEventData)=>{
718
+ this._handleArrowKeyPress(evt, domEventData);
719
+ }, {
720
+ context: [
721
+ isWidget,
722
+ '$text'
723
+ ],
724
+ priority: 'high'
725
+ });
726
+ // This listener makes sure the widget type around selection attribute will be gone from the model
727
+ // selection as soon as the model range changes. This attribute only makes sense when a widget is selected
728
+ // (and the "fake horizontal caret" is visible) so whenever the range changes (e.g. selection moved somewhere else),
729
+ // let's get rid of the attribute so that the selection downcast dispatcher isn't even bothered.
730
+ this._listenToIfEnabled(modelSelection, 'change:range', (evt, data)=>{
731
+ // Do not reset the selection attribute when the change was indirect.
732
+ if (!data.directChange) {
733
+ return;
734
+ }
735
+ // Get rid of the widget type around attribute of the selection on every change:range.
736
+ // If the range changes, it means for sure, the user is no longer in the active ("fake horizontal caret") mode.
737
+ editor.model.change((writer)=>{
738
+ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
739
+ });
740
+ });
741
+ // Get rid of the widget type around attribute of the selection on every document change
742
+ // that makes widget not selected any more (i.e. widget was removed).
743
+ this._listenToIfEnabled(model.document, 'change:data', ()=>{
744
+ const selectedModelElement = modelSelection.getSelectedElement();
745
+ if (selectedModelElement) {
746
+ const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
747
+ if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
748
+ return;
749
+ }
750
+ }
751
+ editor.model.change((writer)=>{
752
+ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
753
+ });
754
+ });
755
+ // React to changes of the model selection attribute made by the arrow keys listener.
756
+ // If the block widget is selected and the attribute changes, downcast the attribute to special
757
+ // CSS classes associated with the active ("fake horizontal caret") mode of the widget.
758
+ this._listenToIfEnabled(editor.editing.downcastDispatcher, 'selection', (evt, data, conversionApi)=>{
759
+ const writer = conversionApi.writer;
760
+ if (this._currentFakeCaretModelElement) {
761
+ const selectedViewElement = conversionApi.mapper.toViewElement(this._currentFakeCaretModelElement);
762
+ if (selectedViewElement) {
763
+ // Get rid of CSS classes associated with the active ("fake horizontal caret") mode from the view widget.
764
+ writer.removeClass(POSSIBLE_INSERTION_POSITIONS.map(positionToWidgetCssClass), selectedViewElement);
765
+ this._currentFakeCaretModelElement = null;
766
+ }
767
+ }
768
+ const selectedModelElement = data.selection.getSelectedElement();
769
+ if (!selectedModelElement) {
770
+ return;
771
+ }
772
+ const selectedViewElement = conversionApi.mapper.toViewElement(selectedModelElement);
773
+ if (!isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
774
+ return;
775
+ }
776
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(data.selection);
777
+ if (!typeAroundFakeCaretPosition) {
778
+ return;
779
+ }
780
+ writer.addClass(positionToWidgetCssClass(typeAroundFakeCaretPosition), selectedViewElement);
781
+ // Remember the view widget that got the "fake-caret" CSS class. This class should be removed ASAP when the
782
+ // selection changes
783
+ this._currentFakeCaretModelElement = selectedModelElement;
784
+ });
785
+ this._listenToIfEnabled(editor.ui.focusTracker, 'change:isFocused', (evt, name, isFocused)=>{
786
+ if (!isFocused) {
787
+ editor.model.change((writer)=>{
788
+ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
789
+ });
790
+ }
791
+ });
792
+ function positionToWidgetCssClass(position) {
793
+ return `ck-widget_type-around_show-fake-caret_${position}`;
794
+ }
795
+ }
796
+ /**
797
+ * A listener executed on each "keydown" in the view document, a part of
798
+ * {@link #_enableTypeAroundFakeCaretActivationUsingKeyboardArrows}.
799
+ *
800
+ * It decides whether the arrow keypress should activate the fake caret or not (also whether it should
801
+ * be deactivated).
802
+ *
803
+ * The fake caret activation is done by setting the `widget-type-around` model selection attribute
804
+ * in this listener, and stopping and preventing the event that would normally be handled by the widget
805
+ * plugin that is responsible for the regular keyboard navigation near/across all widgets (that
806
+ * includes inline widgets, which are ignored by the widget type around plugin).
807
+ */ _handleArrowKeyPress(evt, domEventData) {
808
+ const editor = this.editor;
809
+ const model = editor.model;
810
+ const modelSelection = model.document.selection;
811
+ const schema = model.schema;
812
+ const editingView = editor.editing.view;
813
+ const keyCode = domEventData.keyCode;
814
+ const isForward = isForwardArrowKeyCode(keyCode, editor.locale.contentLanguageDirection);
815
+ const selectedViewElement = editingView.document.selection.getSelectedElement();
816
+ const selectedModelElement = editor.editing.mapper.toModelElement(selectedViewElement);
817
+ let shouldStopAndPreventDefault;
818
+ // Handle keyboard navigation when a type-around-compatible widget is currently selected.
819
+ if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
820
+ shouldStopAndPreventDefault = this._handleArrowKeyPressOnSelectedWidget(isForward);
821
+ } else if (modelSelection.isCollapsed) {
822
+ shouldStopAndPreventDefault = this._handleArrowKeyPressWhenSelectionNextToAWidget(isForward);
823
+ } else if (!domEventData.shiftKey) {
824
+ shouldStopAndPreventDefault = this._handleArrowKeyPressWhenNonCollapsedSelection(isForward);
825
+ }
826
+ if (shouldStopAndPreventDefault) {
827
+ domEventData.preventDefault();
828
+ evt.stop();
829
+ }
830
+ }
831
+ /**
832
+ * Handles the keyboard navigation on "keydown" when a widget is currently selected and activates or deactivates
833
+ * the fake caret for that widget, depending on the current value of the `widget-type-around` model
834
+ * selection attribute and the direction of the pressed arrow key.
835
+ *
836
+ * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
837
+ * as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
838
+ * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
839
+ * process the event any further. Returns `false` otherwise.
840
+ */ _handleArrowKeyPressOnSelectedWidget(isForward) {
841
+ const editor = this.editor;
842
+ const model = editor.model;
843
+ const modelSelection = model.document.selection;
844
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection);
845
+ return model.change((writer)=>{
846
+ // If the fake caret is displayed...
847
+ if (typeAroundFakeCaretPosition) {
848
+ const isLeavingWidget = typeAroundFakeCaretPosition === (isForward ? 'after' : 'before');
849
+ // If the keyboard arrow works against the value of the selection attribute...
850
+ // then remove the selection attribute but prevent default DOM actions
851
+ // and do not let the Widget plugin listener move the selection. This brings
852
+ // the widget back to the state, for instance, like if was selected using the mouse.
853
+ //
854
+ // **Note**: If leaving the widget when the fake caret is active, then the default
855
+ // Widget handler will change the selection and, in turn, this will automatically discard
856
+ // the selection attribute.
857
+ if (!isLeavingWidget) {
858
+ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
859
+ return true;
860
+ }
861
+ } else {
862
+ writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before');
863
+ return true;
864
+ }
865
+ return false;
866
+ });
867
+ }
868
+ /**
869
+ * Handles the keyboard navigation on "keydown" when **no** widget is selected but the selection is **directly** next
870
+ * to one and upon the fake caret should become active for this widget upon arrow keypress
871
+ * (AKA entering/selecting the widget).
872
+ *
873
+ * **Note**: This code mirrors the implementation from the widget plugin but also adds the selection attribute.
874
+ * Unfortunately, there is no safe way to let the widget plugin do the selection part first and then just set the
875
+ * selection attribute here in the widget type around plugin. This is why this code must duplicate some from the widget plugin.
876
+ *
877
+ * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
878
+ * as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
879
+ * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
880
+ * process the event any further. Returns `false` otherwise.
881
+ */ _handleArrowKeyPressWhenSelectionNextToAWidget(isForward) {
882
+ const editor = this.editor;
883
+ const model = editor.model;
884
+ const schema = model.schema;
885
+ const widgetPlugin = editor.plugins.get('Widget');
886
+ // This is the widget the selection is about to be set on.
887
+ const modelElementNextToSelection = widgetPlugin._getObjectElementNextToSelection(isForward);
888
+ const viewElementNextToSelection = editor.editing.mapper.toViewElement(modelElementNextToSelection);
889
+ if (isTypeAroundWidget(viewElementNextToSelection, modelElementNextToSelection, schema)) {
890
+ model.change((writer)=>{
891
+ widgetPlugin._setSelectionOverElement(modelElementNextToSelection);
892
+ writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'before' : 'after');
893
+ });
894
+ // The change() block above does the same job as the Widget plugin. The event can
895
+ // be safely canceled.
896
+ return true;
897
+ }
898
+ return false;
899
+ }
900
+ /**
901
+ * Handles the keyboard navigation on "keydown" when a widget is currently selected (together with some other content)
902
+ * and the widget is the first or last element in the selection. It activates or deactivates the fake caret for that widget.
903
+ *
904
+ * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
905
+ * as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
906
+ * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
907
+ * process the event any further. Returns `false` otherwise.
908
+ */ _handleArrowKeyPressWhenNonCollapsedSelection(isForward) {
909
+ const editor = this.editor;
910
+ const model = editor.model;
911
+ const schema = model.schema;
912
+ const mapper = editor.editing.mapper;
913
+ const modelSelection = model.document.selection;
914
+ const selectedModelNode = isForward ? modelSelection.getLastPosition().nodeBefore : modelSelection.getFirstPosition().nodeAfter;
915
+ const selectedViewNode = mapper.toViewElement(selectedModelNode);
916
+ // There is a widget at the collapse position so collapse the selection to the fake caret on it.
917
+ if (isTypeAroundWidget(selectedViewNode, selectedModelNode, schema)) {
918
+ model.change((writer)=>{
919
+ writer.setSelection(selectedModelNode, 'on');
920
+ writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before');
921
+ });
922
+ return true;
923
+ }
924
+ return false;
925
+ }
926
+ /**
927
+ * Registers a `mousedown` listener for the view document which intercepts events
928
+ * coming from the widget type around UI, which happens when a user clicks one of the buttons
929
+ * that insert a paragraph next to a widget.
930
+ */ _enableInsertingParagraphsOnButtonClick() {
931
+ const editor = this.editor;
932
+ const editingView = editor.editing.view;
933
+ this._listenToIfEnabled(editingView.document, 'mousedown', (evt, domEventData)=>{
934
+ const button = getClosestTypeAroundDomButton(domEventData.domTarget);
935
+ if (!button) {
936
+ return;
937
+ }
938
+ const buttonPosition = getTypeAroundButtonPosition(button);
939
+ const widgetViewElement = getClosestWidgetViewElement(button, editingView.domConverter);
940
+ const widgetModelElement = editor.editing.mapper.toModelElement(widgetViewElement);
941
+ this._insertParagraph(widgetModelElement, buttonPosition);
942
+ domEventData.preventDefault();
943
+ evt.stop();
944
+ });
945
+ }
946
+ /**
947
+ * Creates the <kbd>Enter</kbd> key listener on the view document that allows the user to insert a paragraph
948
+ * near the widget when either:
949
+ *
950
+ * * The fake caret was first activated using the arrow keys,
951
+ * * The entire widget is selected in the model.
952
+ *
953
+ * In the first case, the new paragraph is inserted according to the `widget-type-around` selection
954
+ * attribute (see {@link #_handleArrowKeyPress}).
955
+ *
956
+ * In the second case, the new paragraph is inserted based on whether a soft (<kbd>Shift</kbd>+<kbd>Enter</kbd>) keystroke
957
+ * was pressed or not.
958
+ */ _enableInsertingParagraphsOnEnterKeypress() {
959
+ const editor = this.editor;
960
+ const selection = editor.model.document.selection;
961
+ const editingView = editor.editing.view;
962
+ this._listenToIfEnabled(editingView.document, 'enter', (evt, domEventData)=>{
963
+ // This event could be triggered from inside the widget but we are interested
964
+ // only when the widget is selected itself.
965
+ if (evt.eventPhase != 'atTarget') {
966
+ return;
967
+ }
968
+ const selectedModelElement = selection.getSelectedElement();
969
+ const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
970
+ const schema = editor.model.schema;
971
+ let wasHandled;
972
+ // First check if the widget is selected and there's a type around selection attribute associated
973
+ // with the fake caret that would tell where to insert a new paragraph.
974
+ if (this._insertParagraphAccordingToFakeCaretPosition()) {
975
+ wasHandled = true;
976
+ } else if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
977
+ this._insertParagraph(selectedModelElement, domEventData.isSoft ? 'before' : 'after');
978
+ wasHandled = true;
979
+ }
980
+ if (wasHandled) {
981
+ domEventData.preventDefault();
982
+ evt.stop();
983
+ }
984
+ }, {
985
+ context: isWidget
986
+ });
987
+ }
988
+ /**
989
+ * Similar to the {@link #_enableInsertingParagraphsOnEnterKeypress}, it allows the user
990
+ * to insert a paragraph next to a widget when the fake caret was activated using arrow
991
+ * keys but it responds to typing instead of <kbd>Enter</kbd>.
992
+ *
993
+ * Listener enabled by this method will insert a new paragraph according to the `widget-type-around`
994
+ * model selection attribute as the user simply starts typing, which creates the impression that the fake caret
995
+ * behaves like a real one rendered by the browser (AKA your text appears where the caret was).
996
+ *
997
+ * **Note**: At the moment this listener creates 2 undo steps: one for the `insertParagraph` command
998
+ * and another one for actual typing. It is not a disaster but this may need to be fixed
999
+ * sooner or later.
1000
+ */ _enableInsertingParagraphsOnTypingKeystroke() {
1001
+ const editor = this.editor;
1002
+ const viewDocument = editor.editing.view.document;
1003
+ // Note: The priority must precede the default Input plugin insertText handler.
1004
+ this._listenToIfEnabled(viewDocument, 'insertText', (evt, data)=>{
1005
+ if (this._insertParagraphAccordingToFakeCaretPosition()) {
1006
+ // The view selection in the event data contains the widget. If the new paragraph
1007
+ // was inserted, modify the view selection passed along with the insertText event
1008
+ // so the default event handler in the Input plugin starts typing inside the paragraph.
1009
+ // Otherwise, the typing would be over the widget.
1010
+ data.selection = viewDocument.selection;
1011
+ }
1012
+ }, {
1013
+ priority: 'high'
1014
+ });
1015
+ if (env.isAndroid) {
1016
+ // On Android with English keyboard, the composition starts just by putting caret
1017
+ // at the word end or by selecting a table column. This is not a real composition started.
1018
+ // Trigger delete content on first composition key pressed.
1019
+ this._listenToIfEnabled(viewDocument, 'keydown', (evt, data)=>{
1020
+ if (data.keyCode == 229) {
1021
+ this._insertParagraphAccordingToFakeCaretPosition();
1022
+ }
1023
+ });
1024
+ } else {
1025
+ // Note: The priority must precede the default Input plugin compositionstart handler (to call it before delete content).
1026
+ this._listenToIfEnabled(viewDocument, 'compositionstart', ()=>{
1027
+ this._insertParagraphAccordingToFakeCaretPosition();
1028
+ }, {
1029
+ priority: 'high'
1030
+ });
1031
+ }
1032
+ }
1033
+ /**
1034
+ * It creates a "delete" event listener on the view document to handle cases when the <kbd>Delete</kbd> or <kbd>Backspace</kbd>
1035
+ * is pressed and the fake caret is currently active.
1036
+ *
1037
+ * The fake caret should create an illusion of a real browser caret so that when it appears before or after
1038
+ * a widget, pressing <kbd>Delete</kbd> or <kbd>Backspace</kbd> should remove a widget or delete the content
1039
+ * before or after a widget (depending on the content surrounding the widget).
1040
+ */ _enableDeleteIntegration() {
1041
+ const editor = this.editor;
1042
+ const editingView = editor.editing.view;
1043
+ const model = editor.model;
1044
+ const schema = model.schema;
1045
+ this._listenToIfEnabled(editingView.document, 'delete', (evt, domEventData)=>{
1046
+ // This event could be triggered from inside the widget but we are interested
1047
+ // only when the widget is selected itself.
1048
+ if (evt.eventPhase != 'atTarget') {
1049
+ return;
1050
+ }
1051
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(model.document.selection);
1052
+ // This listener handles only these cases when the fake caret is active.
1053
+ if (!typeAroundFakeCaretPosition) {
1054
+ return;
1055
+ }
1056
+ const direction = domEventData.direction;
1057
+ const selectedModelWidget = model.document.selection.getSelectedElement();
1058
+ const isFakeCaretBefore = typeAroundFakeCaretPosition === 'before';
1059
+ const isDeleteForward = direction == 'forward';
1060
+ const shouldDeleteEntireWidget = isFakeCaretBefore === isDeleteForward;
1061
+ if (shouldDeleteEntireWidget) {
1062
+ editor.execute('delete', {
1063
+ selection: model.createSelection(selectedModelWidget, 'on')
1064
+ });
1065
+ } else {
1066
+ const range = schema.getNearestSelectionRange(model.createPositionAt(selectedModelWidget, typeAroundFakeCaretPosition), direction);
1067
+ // If there is somewhere to move selection to, then there will be something to delete.
1068
+ if (range) {
1069
+ // If the range is NOT collapsed, then we know that the range contains an object (see getNearestSelectionRange() docs).
1070
+ if (!range.isCollapsed) {
1071
+ model.change((writer)=>{
1072
+ writer.setSelection(range);
1073
+ editor.execute(isDeleteForward ? 'deleteForward' : 'delete');
1074
+ });
1075
+ } else {
1076
+ const probe = model.createSelection(range.start);
1077
+ model.modifySelection(probe, {
1078
+ direction
1079
+ });
1080
+ // If the range is collapsed, let's see if a non-collapsed range exists that can could be deleted.
1081
+ // If such range exists, use the editor command because it it safe for collaboration (it merges where it can).
1082
+ if (!probe.focus.isEqual(range.start)) {
1083
+ model.change((writer)=>{
1084
+ writer.setSelection(range);
1085
+ editor.execute(isDeleteForward ? 'deleteForward' : 'delete');
1086
+ });
1087
+ } else {
1088
+ const deepestEmptyRangeAncestor = getDeepestEmptyElementAncestor(schema, range.start.parent);
1089
+ model.deleteContent(model.createSelection(deepestEmptyRangeAncestor, 'on'), {
1090
+ doNotAutoparagraph: true
1091
+ });
1092
+ }
1093
+ }
1094
+ }
1095
+ }
1096
+ // If some content was deleted, don't let the handler from the Widget plugin kick in.
1097
+ // If nothing was deleted, then the default handler will have nothing to do anyway.
1098
+ domEventData.preventDefault();
1099
+ evt.stop();
1100
+ }, {
1101
+ context: isWidget
1102
+ });
1103
+ }
1104
+ /**
1105
+ * Attaches the {@link module:engine/model/model~Model#event:insertContent} event listener that, for instance, allows the user to paste
1106
+ * content near a widget when the fake caret is first activated using the arrow keys.
1107
+ *
1108
+ * The content is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
1109
+ */ _enableInsertContentIntegration() {
1110
+ const editor = this.editor;
1111
+ const model = this.editor.model;
1112
+ const documentSelection = model.document.selection;
1113
+ this._listenToIfEnabled(editor.model, 'insertContent', (evt, [content, selectable])=>{
1114
+ if (selectable && !selectable.is('documentSelection')) {
1115
+ return;
1116
+ }
1117
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
1118
+ if (!typeAroundFakeCaretPosition) {
1119
+ return;
1120
+ }
1121
+ evt.stop();
1122
+ return model.change((writer)=>{
1123
+ const selectedElement = documentSelection.getSelectedElement();
1124
+ const position = model.createPositionAt(selectedElement, typeAroundFakeCaretPosition);
1125
+ const selection = writer.createSelection(position);
1126
+ const result = model.insertContent(content, selection);
1127
+ writer.setSelection(selection);
1128
+ return result;
1129
+ });
1130
+ }, {
1131
+ priority: 'high'
1132
+ });
1133
+ }
1134
+ /**
1135
+ * Attaches the {@link module:engine/model/model~Model#event:insertObject} event listener that modifies the
1136
+ * `options.findOptimalPosition`parameter to position of fake caret in relation to selected element
1137
+ * to reflect user's intent of desired insertion position.
1138
+ *
1139
+ * The object is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
1140
+ */ _enableInsertObjectIntegration() {
1141
+ const editor = this.editor;
1142
+ const model = this.editor.model;
1143
+ const documentSelection = model.document.selection;
1144
+ this._listenToIfEnabled(editor.model, 'insertObject', (evt, args)=>{
1145
+ const [, selectable, options = {}] = args;
1146
+ if (selectable && !selectable.is('documentSelection')) {
1147
+ return;
1148
+ }
1149
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
1150
+ if (!typeAroundFakeCaretPosition) {
1151
+ return;
1152
+ }
1153
+ options.findOptimalPosition = typeAroundFakeCaretPosition;
1154
+ args[3] = options;
1155
+ }, {
1156
+ priority: 'high'
1157
+ });
1158
+ }
1159
+ /**
1160
+ * Attaches the {@link module:engine/model/model~Model#event:deleteContent} event listener to block the event when the fake
1161
+ * caret is active.
1162
+ *
1163
+ * This is required for cases that trigger {@link module:engine/model/model~Model#deleteContent `model.deleteContent()`}
1164
+ * before calling {@link module:engine/model/model~Model#insertContent `model.insertContent()`} like, for instance,
1165
+ * plain text pasting.
1166
+ */ _enableDeleteContentIntegration() {
1167
+ const editor = this.editor;
1168
+ const model = this.editor.model;
1169
+ const documentSelection = model.document.selection;
1170
+ this._listenToIfEnabled(editor.model, 'deleteContent', (evt, [selection])=>{
1171
+ if (selection && !selection.is('documentSelection')) {
1172
+ return;
1173
+ }
1174
+ const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
1175
+ // Disable removing the selection content while pasting plain text.
1176
+ if (typeAroundFakeCaretPosition) {
1177
+ evt.stop();
1178
+ }
1179
+ }, {
1180
+ priority: 'high'
1181
+ });
1182
+ }
1183
+ constructor(){
1184
+ super(...arguments);
1185
+ /**
1186
+ * A reference to the model widget element that has the fake caret active
1187
+ * on either side of it. It is later used to remove CSS classes associated with the fake caret
1188
+ * when the widget no longer needs it.
1189
+ */ this._currentFakeCaretModelElement = null;
1190
+ }
1191
+ }
1192
+ /**
1193
+ * Injects the type around UI into a view widget instance.
1194
+ */ function injectUIIntoWidget(viewWriter, buttonTitles, widgetViewElement) {
1195
+ const typeAroundWrapper = viewWriter.createUIElement('div', {
1196
+ class: 'ck ck-reset_all ck-widget__type-around'
1197
+ }, function(domDocument) {
1198
+ const wrapperDomElement = this.toDomElement(domDocument);
1199
+ injectButtons(wrapperDomElement, buttonTitles);
1200
+ injectFakeCaret(wrapperDomElement);
1201
+ return wrapperDomElement;
1202
+ });
1203
+ // Inject the type around wrapper into the widget's wrapper.
1204
+ viewWriter.insert(viewWriter.createPositionAt(widgetViewElement, 'end'), typeAroundWrapper);
1205
+ }
1206
+ /**
1207
+ * FYI: Not using the IconView class because each instance would need to be destroyed to avoid memory leaks
1208
+ * and it's pretty hard to figure out when a view (widget) is gone for good so it's cheaper to use raw
1209
+ * <svg> here.
1210
+ */ function injectButtons(wrapperDomElement, buttonTitles) {
1211
+ for (const position of POSSIBLE_INSERTION_POSITIONS){
1212
+ const buttonTemplate = new Template({
1213
+ tag: 'div',
1214
+ attributes: {
1215
+ class: [
1216
+ 'ck',
1217
+ 'ck-widget__type-around__button',
1218
+ `ck-widget__type-around__button_${position}`
1219
+ ],
1220
+ title: buttonTitles[position],
1221
+ 'aria-hidden': 'true'
1222
+ },
1223
+ children: [
1224
+ wrapperDomElement.ownerDocument.importNode(RETURN_ARROW_ICON_ELEMENT, true)
1225
+ ]
1226
+ });
1227
+ wrapperDomElement.appendChild(buttonTemplate.render());
1228
+ }
1229
+ }
1230
+ function injectFakeCaret(wrapperDomElement) {
1231
+ const caretTemplate = new Template({
1232
+ tag: 'div',
1233
+ attributes: {
1234
+ class: [
1235
+ 'ck',
1236
+ 'ck-widget__type-around__fake-caret'
1237
+ ]
1238
+ }
1239
+ });
1240
+ wrapperDomElement.appendChild(caretTemplate.render());
1241
+ }
1242
+ /**
1243
+ * Returns the ancestor of an element closest to the root which is empty. For instance,
1244
+ * for `<baz>`:
1245
+ *
1246
+ * ```
1247
+ * <foo>abc<bar><baz></baz></bar></foo>
1248
+ * ```
1249
+ *
1250
+ * it returns `<bar>`.
1251
+ */ function getDeepestEmptyElementAncestor(schema, element) {
1252
+ let deepestEmptyAncestor = element;
1253
+ for (const ancestor of element.getAncestors({
1254
+ parentFirst: true
1255
+ })){
1256
+ if (ancestor.childCount > 1 || schema.isLimit(ancestor)) {
1257
+ break;
1258
+ }
1259
+ deepestEmptyAncestor = ancestor;
1260
+ }
1261
+ return deepestEmptyAncestor;
1262
+ }
1263
+
1264
+ /**
1265
+ * Returns 'keydown' handler for up/down arrow keys that modifies the caret movement if it's in a text line next to an object.
1266
+ *
1267
+ * @param editing The editing controller.
1268
+ */ function verticalNavigationHandler(editing) {
1269
+ const model = editing.model;
1270
+ return (evt, data)=>{
1271
+ const arrowUpPressed = data.keyCode == keyCodes.arrowup;
1272
+ const arrowDownPressed = data.keyCode == keyCodes.arrowdown;
1273
+ const expandSelection = data.shiftKey;
1274
+ const selection = model.document.selection;
1275
+ if (!arrowUpPressed && !arrowDownPressed) {
1276
+ return;
1277
+ }
1278
+ const isForward = arrowDownPressed;
1279
+ // Navigation is in the opposite direction than the selection direction so this is shrinking of the selection.
1280
+ // Selection for sure will not approach any object.
1281
+ if (expandSelection && selectionWillShrink(selection, isForward)) {
1282
+ return;
1283
+ }
1284
+ // Find a range between selection and closest limit element.
1285
+ const range = findTextRangeFromSelection(editing, selection, isForward);
1286
+ // There is no selection position inside the limit element.
1287
+ if (!range) {
1288
+ return;
1289
+ }
1290
+ // If already at the edge of a limit element.
1291
+ if (range.isCollapsed) {
1292
+ // A collapsed selection at limit edge - nothing more to do.
1293
+ if (selection.isCollapsed) {
1294
+ return;
1295
+ } else if (expandSelection) {
1296
+ return;
1297
+ }
1298
+ }
1299
+ // If the range is a single line (there is no word wrapping) then move the selection to the position closest to the limit element.
1300
+ //
1301
+ // We can't move the selection directly to the isObject element (eg. table cell) because of dual position at the end/beginning
1302
+ // of wrapped line (it's at the same time at the end of one line and at the start of the next line).
1303
+ if (range.isCollapsed || isSingleLineRange(editing, range, isForward)) {
1304
+ model.change((writer)=>{
1305
+ const newPosition = isForward ? range.end : range.start;
1306
+ if (expandSelection) {
1307
+ const newSelection = model.createSelection(selection.anchor);
1308
+ newSelection.setFocus(newPosition);
1309
+ writer.setSelection(newSelection);
1310
+ } else {
1311
+ writer.setSelection(newPosition);
1312
+ }
1313
+ });
1314
+ evt.stop();
1315
+ data.preventDefault();
1316
+ data.stopPropagation();
1317
+ }
1318
+ };
1319
+ }
1320
+ /**
1321
+ * Finds the range between selection and closest limit element (in the direction of navigation).
1322
+ * The position next to limit element is adjusted to the closest allowed `$text` position.
1323
+ *
1324
+ * Returns `null` if, according to the schema, the resulting range cannot contain a `$text` element.
1325
+ *
1326
+ * @param editing The editing controller.
1327
+ * @param selection The current selection.
1328
+ * @param isForward The expected navigation direction.
1329
+ */ function findTextRangeFromSelection(editing, selection, isForward) {
1330
+ const model = editing.model;
1331
+ if (isForward) {
1332
+ const startPosition = selection.isCollapsed ? selection.focus : selection.getLastPosition();
1333
+ const endPosition = getNearestNonInlineLimit(model, startPosition, 'forward');
1334
+ // There is no limit element, browser should handle this.
1335
+ if (!endPosition) {
1336
+ return null;
1337
+ }
1338
+ const range = model.createRange(startPosition, endPosition);
1339
+ const lastRangePosition = getNearestTextPosition(model.schema, range, 'backward');
1340
+ if (lastRangePosition) {
1341
+ return model.createRange(startPosition, lastRangePosition);
1342
+ }
1343
+ return null;
1344
+ } else {
1345
+ const endPosition = selection.isCollapsed ? selection.focus : selection.getFirstPosition();
1346
+ const startPosition = getNearestNonInlineLimit(model, endPosition, 'backward');
1347
+ // There is no limit element, browser should handle this.
1348
+ if (!startPosition) {
1349
+ return null;
1350
+ }
1351
+ const range = model.createRange(startPosition, endPosition);
1352
+ const firstRangePosition = getNearestTextPosition(model.schema, range, 'forward');
1353
+ if (firstRangePosition) {
1354
+ return model.createRange(firstRangePosition, endPosition);
1355
+ }
1356
+ return null;
1357
+ }
1358
+ }
1359
+ /**
1360
+ * Finds the limit element position that is closest to startPosition.
1361
+ *
1362
+ * @param direction Search direction.
1363
+ */ function getNearestNonInlineLimit(model, startPosition, direction) {
1364
+ const schema = model.schema;
1365
+ const range = model.createRangeIn(startPosition.root);
1366
+ const walkerValueType = direction == 'forward' ? 'elementStart' : 'elementEnd';
1367
+ for (const { previousPosition, item, type } of range.getWalker({
1368
+ startPosition,
1369
+ direction
1370
+ })){
1371
+ if (schema.isLimit(item) && !schema.isInline(item)) {
1372
+ return previousPosition;
1373
+ }
1374
+ // Stop looking for isLimit element if the next element is a block element (it is for sure not single line).
1375
+ if (type == walkerValueType && schema.isBlock(item)) {
1376
+ return null;
1377
+ }
1378
+ }
1379
+ return null;
1380
+ }
1381
+ /**
1382
+ * Basing on the provided range, finds the first or last (depending on `direction`) position inside the range
1383
+ * that can contain `$text` (according to schema).
1384
+ *
1385
+ * @param schema The schema.
1386
+ * @param range The range to find the position in.
1387
+ * @param direction Search direction.
1388
+ * @returns The nearest selection position.
1389
+ *
1390
+ */ function getNearestTextPosition(schema, range, direction) {
1391
+ const position = direction == 'backward' ? range.end : range.start;
1392
+ if (schema.checkChild(position, '$text')) {
1393
+ return position;
1394
+ }
1395
+ for (const { nextPosition } of range.getWalker({
1396
+ direction
1397
+ })){
1398
+ if (schema.checkChild(nextPosition, '$text')) {
1399
+ return nextPosition;
1400
+ }
1401
+ }
1402
+ return null;
1403
+ }
1404
+ /**
1405
+ * Checks if the DOM range corresponding to the provided model range renders as a single line by analyzing DOMRects
1406
+ * (verifying if they visually wrap content to the next line).
1407
+ *
1408
+ * @param editing The editing controller.
1409
+ * @param modelRange The current table cell content range.
1410
+ * @param isForward The expected navigation direction.
1411
+ */ function isSingleLineRange(editing, modelRange, isForward) {
1412
+ const model = editing.model;
1413
+ const domConverter = editing.view.domConverter;
1414
+ // Wrapped lines contain exactly the same position at the end of current line
1415
+ // and at the beginning of next line. That position's client rect is at the end
1416
+ // of current line. In case of caret at first position of the last line that 'dual'
1417
+ // position would be detected as it's not the last line.
1418
+ if (isForward) {
1419
+ const probe = model.createSelection(modelRange.start);
1420
+ model.modifySelection(probe);
1421
+ // If the new position is at the end of the container then we can't use this position
1422
+ // because it would provide incorrect result for eg caption of image and selection
1423
+ // just before end of it. Also in this case there is no "dual" position.
1424
+ if (!probe.focus.isAtEnd && !modelRange.start.isEqual(probe.focus)) {
1425
+ modelRange = model.createRange(probe.focus, modelRange.end);
1426
+ }
1427
+ }
1428
+ const viewRange = editing.mapper.toViewRange(modelRange);
1429
+ const domRange = domConverter.viewRangeToDom(viewRange);
1430
+ const rects = Rect.getDomRangeRects(domRange);
1431
+ let boundaryVerticalPosition;
1432
+ for (const rect of rects){
1433
+ if (boundaryVerticalPosition === undefined) {
1434
+ boundaryVerticalPosition = Math.round(rect.bottom);
1435
+ continue;
1436
+ }
1437
+ // Let's check if this rect is in new line.
1438
+ if (Math.round(rect.top) >= boundaryVerticalPosition) {
1439
+ return false;
1440
+ }
1441
+ boundaryVerticalPosition = Math.max(boundaryVerticalPosition, Math.round(rect.bottom));
1442
+ }
1443
+ return true;
1444
+ }
1445
+ function selectionWillShrink(selection, isForward) {
1446
+ return !selection.isCollapsed && selection.isBackward == isForward;
1447
+ }
1448
+
1449
+ class Widget extends Plugin {
1450
+ /**
1451
+ * @inheritDoc
1452
+ */ static get pluginName() {
1453
+ return 'Widget';
1454
+ }
1455
+ /**
1456
+ * @inheritDoc
1457
+ */ static get requires() {
1458
+ return [
1459
+ WidgetTypeAround,
1460
+ Delete
1461
+ ];
1462
+ }
1463
+ /**
1464
+ * @inheritDoc
1465
+ */ init() {
1466
+ const editor = this.editor;
1467
+ const view = editor.editing.view;
1468
+ const viewDocument = view.document;
1469
+ const t = editor.t;
1470
+ // Model to view selection converter.
1471
+ // Converts selection placed over widget element to fake selection.
1472
+ //
1473
+ // By default, the selection is downcasted by the engine to surround the attribute element, even though its only
1474
+ // child is an inline widget. A similar thing also happens when a collapsed marker is rendered as a UI element
1475
+ // next to an inline widget: the view selection contains both the widget and the marker.
1476
+ //
1477
+ // This prevents creating a correct fake selection when this inline widget is selected. Normalize the selection
1478
+ // in these cases based on the model:
1479
+ //
1480
+ // [<attributeElement><inlineWidget /></attributeElement>] -> <attributeElement>[<inlineWidget />]</attributeElement>
1481
+ // [<uiElement></uiElement><inlineWidget />] -> <uiElement></uiElement>[<inlineWidget />]
1482
+ //
1483
+ // Thanks to this:
1484
+ //
1485
+ // * fake selection can be set correctly,
1486
+ // * any logic depending on (View)Selection#getSelectedElement() also works OK.
1487
+ //
1488
+ // See https://github.com/ckeditor/ckeditor5/issues/9524.
1489
+ this.editor.editing.downcastDispatcher.on('selection', (evt, data, conversionApi)=>{
1490
+ const viewWriter = conversionApi.writer;
1491
+ const modelSelection = data.selection;
1492
+ // The collapsed selection can't contain any widget.
1493
+ if (modelSelection.isCollapsed) {
1494
+ return;
1495
+ }
1496
+ const selectedModelElement = modelSelection.getSelectedElement();
1497
+ if (!selectedModelElement) {
1498
+ return;
1499
+ }
1500
+ const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
1501
+ if (!isWidget(selectedViewElement)) {
1502
+ return;
1503
+ }
1504
+ if (!conversionApi.consumable.consume(modelSelection, 'selection')) {
1505
+ return;
1506
+ }
1507
+ viewWriter.setSelection(viewWriter.createRangeOn(selectedViewElement), {
1508
+ fake: true,
1509
+ label: getLabel(selectedViewElement)
1510
+ });
1511
+ });
1512
+ // Mark all widgets inside the selection with the css class.
1513
+ // This handler is registered at the 'low' priority so it's triggered after the real selection conversion.
1514
+ this.editor.editing.downcastDispatcher.on('selection', (evt, data, conversionApi)=>{
1515
+ // Remove selected class from previously selected widgets.
1516
+ this._clearPreviouslySelectedWidgets(conversionApi.writer);
1517
+ const viewWriter = conversionApi.writer;
1518
+ const viewSelection = viewWriter.document.selection;
1519
+ let lastMarked = null;
1520
+ for (const range of viewSelection.getRanges()){
1521
+ // Note: There could be multiple selected widgets in a range but no fake selection.
1522
+ // All of them must be marked as selected, for instance [<widget></widget><widget></widget>]
1523
+ for (const value of range){
1524
+ const node = value.item;
1525
+ // Do not mark nested widgets in selected one. See: #4594
1526
+ if (isWidget(node) && !isChild(node, lastMarked)) {
1527
+ viewWriter.addClass(WIDGET_SELECTED_CLASS_NAME, node);
1528
+ this._previouslySelected.add(node);
1529
+ lastMarked = node;
1530
+ }
1531
+ }
1532
+ }
1533
+ }, {
1534
+ priority: 'low'
1535
+ });
1536
+ // If mouse down is pressed on widget - create selection over whole widget.
1537
+ view.addObserver(MouseObserver);
1538
+ this.listenTo(viewDocument, 'mousedown', (...args)=>this._onMousedown(...args));
1539
+ // There are two keydown listeners working on different priorities. This allows other
1540
+ // features such as WidgetTypeAround or TableKeyboard to attach their listeners in between
1541
+ // and customize the behavior even further in different content/selection scenarios.
1542
+ //
1543
+ // * The first listener handles changing the selection on arrow key press
1544
+ // if the widget is selected or if the selection is next to a widget and the widget
1545
+ // should become selected upon the arrow key press.
1546
+ //
1547
+ // * The second (late) listener makes sure the default browser action on arrow key press is
1548
+ // prevented when a widget is selected. This prevents the selection from being moved
1549
+ // from a fake selection container.
1550
+ this.listenTo(viewDocument, 'arrowKey', (...args)=>{
1551
+ this._handleSelectionChangeOnArrowKeyPress(...args);
1552
+ }, {
1553
+ context: [
1554
+ isWidget,
1555
+ '$text'
1556
+ ]
1557
+ });
1558
+ this.listenTo(viewDocument, 'arrowKey', (...args)=>{
1559
+ this._preventDefaultOnArrowKeyPress(...args);
1560
+ }, {
1561
+ context: '$root'
1562
+ });
1563
+ this.listenTo(viewDocument, 'arrowKey', verticalNavigationHandler(this.editor.editing), {
1564
+ context: '$text'
1565
+ });
1566
+ // Handle custom delete behaviour.
1567
+ this.listenTo(viewDocument, 'delete', (evt, data)=>{
1568
+ if (this._handleDelete(data.direction == 'forward')) {
1569
+ data.preventDefault();
1570
+ evt.stop();
1571
+ }
1572
+ }, {
1573
+ context: '$root'
1574
+ });
1575
+ // Handle Tab key while a widget is selected.
1576
+ this.listenTo(viewDocument, 'tab', (evt, data)=>{
1577
+ // This event could be triggered from inside the widget, but we are interested
1578
+ // only when the widget is selected itself.
1579
+ if (evt.eventPhase != 'atTarget') {
1580
+ return;
1581
+ }
1582
+ if (data.shiftKey) {
1583
+ return;
1584
+ }
1585
+ if (this._selectFirstNestedEditable()) {
1586
+ data.preventDefault();
1587
+ evt.stop();
1588
+ }
1589
+ }, {
1590
+ context: isWidget,
1591
+ priority: 'low'
1592
+ });
1593
+ // Handle Shift+Tab key while caret inside a widget editable.
1594
+ this.listenTo(viewDocument, 'tab', (evt, data)=>{
1595
+ if (!data.shiftKey) {
1596
+ return;
1597
+ }
1598
+ if (this._selectAncestorWidget()) {
1599
+ data.preventDefault();
1600
+ evt.stop();
1601
+ }
1602
+ }, {
1603
+ priority: 'low'
1604
+ });
1605
+ // Handle Esc key while inside a nested editable.
1606
+ this.listenTo(viewDocument, 'keydown', (evt, data)=>{
1607
+ if (data.keystroke != keyCodes.esc) {
1608
+ return;
1609
+ }
1610
+ if (this._selectAncestorWidget()) {
1611
+ data.preventDefault();
1612
+ evt.stop();
1613
+ }
1614
+ }, {
1615
+ priority: 'low'
1616
+ });
1617
+ // Add the information about the keystrokes to the accessibility database.
1618
+ editor.accessibility.addKeystrokeInfoGroup({
1619
+ id: 'widget',
1620
+ label: t('Keystrokes that can be used when a widget is selected (for example: image, table, etc.)'),
1621
+ keystrokes: [
1622
+ {
1623
+ label: t('Insert a new paragraph directly after a widget'),
1624
+ keystroke: 'Enter'
1625
+ },
1626
+ {
1627
+ label: t('Insert a new paragraph directly before a widget'),
1628
+ keystroke: 'Shift+Enter'
1629
+ },
1630
+ {
1631
+ label: t('Move the caret to allow typing directly before a widget'),
1632
+ keystroke: [
1633
+ [
1634
+ 'arrowup'
1635
+ ],
1636
+ [
1637
+ 'arrowleft'
1638
+ ]
1639
+ ]
1640
+ },
1641
+ {
1642
+ label: t('Move the caret to allow typing directly after a widget'),
1643
+ keystroke: [
1644
+ [
1645
+ 'arrowdown'
1646
+ ],
1647
+ [
1648
+ 'arrowright'
1649
+ ]
1650
+ ]
1651
+ }
1652
+ ]
1653
+ });
1654
+ }
1655
+ /**
1656
+ * Handles {@link module:engine/view/document~Document#event:mousedown mousedown} events on widget elements.
1657
+ */ _onMousedown(eventInfo, domEventData) {
1658
+ const editor = this.editor;
1659
+ const view = editor.editing.view;
1660
+ const viewDocument = view.document;
1661
+ let element = domEventData.target;
1662
+ // If triple click should select entire paragraph.
1663
+ if (domEventData.domEvent.detail >= 3) {
1664
+ if (this._selectBlockContent(element)) {
1665
+ domEventData.preventDefault();
1666
+ }
1667
+ return;
1668
+ }
1669
+ // Do nothing for single or double click inside nested editable.
1670
+ if (isInsideNestedEditable(element)) {
1671
+ return;
1672
+ }
1673
+ // If target is not a widget element - check if one of the ancestors is.
1674
+ if (!isWidget(element)) {
1675
+ element = element.findAncestor(isWidget);
1676
+ if (!element) {
1677
+ return;
1678
+ }
1679
+ }
1680
+ // On Android selection would jump to the first table cell, on other devices
1681
+ // we can't block it (and don't need to) because of drag and drop support.
1682
+ if (env.isAndroid) {
1683
+ domEventData.preventDefault();
1684
+ }
1685
+ // Focus editor if is not focused already.
1686
+ if (!viewDocument.isFocused) {
1687
+ view.focus();
1688
+ }
1689
+ // Create model selection over widget.
1690
+ const modelElement = editor.editing.mapper.toModelElement(element);
1691
+ this._setSelectionOverElement(modelElement);
1692
+ }
1693
+ /**
1694
+ * Selects entire block content, e.g. on triple click it selects entire paragraph.
1695
+ */ _selectBlockContent(element) {
1696
+ const editor = this.editor;
1697
+ const model = editor.model;
1698
+ const mapper = editor.editing.mapper;
1699
+ const schema = model.schema;
1700
+ const viewElement = mapper.findMappedViewAncestor(this.editor.editing.view.createPositionAt(element, 0));
1701
+ const modelElement = findTextBlockAncestor(mapper.toModelElement(viewElement), model.schema);
1702
+ if (!modelElement) {
1703
+ return false;
1704
+ }
1705
+ model.change((writer)=>{
1706
+ const nextTextBlock = !schema.isLimit(modelElement) ? findNextTextBlock(writer.createPositionAfter(modelElement), schema) : null;
1707
+ const start = writer.createPositionAt(modelElement, 0);
1708
+ const end = nextTextBlock ? writer.createPositionAt(nextTextBlock, 0) : writer.createPositionAt(modelElement, 'end');
1709
+ writer.setSelection(writer.createRange(start, end));
1710
+ });
1711
+ return true;
1712
+ }
1713
+ /**
1714
+ * Handles {@link module:engine/view/document~Document#event:keydown keydown} events and changes
1715
+ * the model selection when:
1716
+ *
1717
+ * * arrow key is pressed when the widget is selected,
1718
+ * * the selection is next to a widget and the widget should become selected upon the arrow key press.
1719
+ *
1720
+ * See {@link #_preventDefaultOnArrowKeyPress}.
1721
+ */ _handleSelectionChangeOnArrowKeyPress(eventInfo, domEventData) {
1722
+ const keyCode = domEventData.keyCode;
1723
+ const model = this.editor.model;
1724
+ const schema = model.schema;
1725
+ const modelSelection = model.document.selection;
1726
+ const objectElement = modelSelection.getSelectedElement();
1727
+ const direction = getLocalizedArrowKeyCodeDirection(keyCode, this.editor.locale.contentLanguageDirection);
1728
+ const isForward = direction == 'down' || direction == 'right';
1729
+ const isVerticalNavigation = direction == 'up' || direction == 'down';
1730
+ // If object element is selected.
1731
+ if (objectElement && schema.isObject(objectElement)) {
1732
+ const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition();
1733
+ const newRange = schema.getNearestSelectionRange(position, isForward ? 'forward' : 'backward');
1734
+ if (newRange) {
1735
+ model.change((writer)=>{
1736
+ writer.setSelection(newRange);
1737
+ });
1738
+ domEventData.preventDefault();
1739
+ eventInfo.stop();
1740
+ }
1741
+ return;
1742
+ }
1743
+ // Handle collapsing of the selection when there is any widget on the edge of selection.
1744
+ // This is needed because browsers have problems with collapsing such selection.
1745
+ if (!modelSelection.isCollapsed && !domEventData.shiftKey) {
1746
+ const firstPosition = modelSelection.getFirstPosition();
1747
+ const lastPosition = modelSelection.getLastPosition();
1748
+ const firstSelectedNode = firstPosition.nodeAfter;
1749
+ const lastSelectedNode = lastPosition.nodeBefore;
1750
+ if (firstSelectedNode && schema.isObject(firstSelectedNode) || lastSelectedNode && schema.isObject(lastSelectedNode)) {
1751
+ model.change((writer)=>{
1752
+ writer.setSelection(isForward ? lastPosition : firstPosition);
1753
+ });
1754
+ domEventData.preventDefault();
1755
+ eventInfo.stop();
1756
+ }
1757
+ return;
1758
+ }
1759
+ // Return if not collapsed.
1760
+ if (!modelSelection.isCollapsed) {
1761
+ return;
1762
+ }
1763
+ // If selection is next to object element.
1764
+ const objectElementNextToSelection = this._getObjectElementNextToSelection(isForward);
1765
+ if (objectElementNextToSelection && schema.isObject(objectElementNextToSelection)) {
1766
+ // Do not select an inline widget while handling up/down arrow.
1767
+ if (schema.isInline(objectElementNextToSelection) && isVerticalNavigation) {
1768
+ return;
1769
+ }
1770
+ this._setSelectionOverElement(objectElementNextToSelection);
1771
+ domEventData.preventDefault();
1772
+ eventInfo.stop();
1773
+ }
1774
+ }
1775
+ /**
1776
+ * Handles {@link module:engine/view/document~Document#event:keydown keydown} events and prevents
1777
+ * the default browser behavior to make sure the fake selection is not being moved from a fake selection
1778
+ * container.
1779
+ *
1780
+ * See {@link #_handleSelectionChangeOnArrowKeyPress}.
1781
+ */ _preventDefaultOnArrowKeyPress(eventInfo, domEventData) {
1782
+ const model = this.editor.model;
1783
+ const schema = model.schema;
1784
+ const objectElement = model.document.selection.getSelectedElement();
1785
+ // If object element is selected.
1786
+ if (objectElement && schema.isObject(objectElement)) {
1787
+ domEventData.preventDefault();
1788
+ eventInfo.stop();
1789
+ }
1790
+ }
1791
+ /**
1792
+ * Handles delete keys: backspace and delete.
1793
+ *
1794
+ * @param isForward Set to true if delete was performed in forward direction.
1795
+ * @returns Returns `true` if keys were handled correctly.
1796
+ */ _handleDelete(isForward) {
1797
+ const modelDocument = this.editor.model.document;
1798
+ const modelSelection = modelDocument.selection;
1799
+ // Do nothing when the read only mode is enabled.
1800
+ if (!this.editor.model.canEditAt(modelSelection)) {
1801
+ return;
1802
+ }
1803
+ // Do nothing on non-collapsed selection.
1804
+ if (!modelSelection.isCollapsed) {
1805
+ return;
1806
+ }
1807
+ const objectElement = this._getObjectElementNextToSelection(isForward);
1808
+ if (objectElement) {
1809
+ this.editor.model.change((writer)=>{
1810
+ let previousNode = modelSelection.anchor.parent;
1811
+ // Remove previous element if empty.
1812
+ while(previousNode.isEmpty){
1813
+ const nodeToRemove = previousNode;
1814
+ previousNode = nodeToRemove.parent;
1815
+ writer.remove(nodeToRemove);
1816
+ }
1817
+ this._setSelectionOverElement(objectElement);
1818
+ });
1819
+ return true;
1820
+ }
1821
+ }
1822
+ /**
1823
+ * Sets {@link module:engine/model/selection~Selection document's selection} over given element.
1824
+ *
1825
+ * @internal
1826
+ */ _setSelectionOverElement(element) {
1827
+ this.editor.model.change((writer)=>{
1828
+ writer.setSelection(writer.createRangeOn(element));
1829
+ });
1830
+ }
1831
+ /**
1832
+ * Checks if {@link module:engine/model/element~Element element} placed next to the current
1833
+ * {@link module:engine/model/selection~Selection model selection} exists and is marked in
1834
+ * {@link module:engine/model/schema~Schema schema} as `object`.
1835
+ *
1836
+ * @internal
1837
+ * @param forward Direction of checking.
1838
+ */ _getObjectElementNextToSelection(forward) {
1839
+ const model = this.editor.model;
1840
+ const schema = model.schema;
1841
+ const modelSelection = model.document.selection;
1842
+ // Clone current selection to use it as a probe. We must leave default selection as it is so it can return
1843
+ // to its current state after undo.
1844
+ const probe = model.createSelection(modelSelection);
1845
+ model.modifySelection(probe, {
1846
+ direction: forward ? 'forward' : 'backward'
1847
+ });
1848
+ // The selection didn't change so there is nothing there.
1849
+ if (probe.isEqual(modelSelection)) {
1850
+ return null;
1851
+ }
1852
+ const objectElement = forward ? probe.focus.nodeBefore : probe.focus.nodeAfter;
1853
+ if (!!objectElement && schema.isObject(objectElement)) {
1854
+ return objectElement;
1855
+ }
1856
+ return null;
1857
+ }
1858
+ /**
1859
+ * Removes CSS class from previously selected widgets.
1860
+ */ _clearPreviouslySelectedWidgets(writer) {
1861
+ for (const widget of this._previouslySelected){
1862
+ writer.removeClass(WIDGET_SELECTED_CLASS_NAME, widget);
1863
+ }
1864
+ this._previouslySelected.clear();
1865
+ }
1866
+ /**
1867
+ * Moves the document selection into the first nested editable.
1868
+ */ _selectFirstNestedEditable() {
1869
+ const editor = this.editor;
1870
+ const view = this.editor.editing.view;
1871
+ const viewDocument = view.document;
1872
+ for (const item of viewDocument.selection.getFirstRange().getItems()){
1873
+ if (item.is('editableElement')) {
1874
+ const modelElement = editor.editing.mapper.toModelElement(item);
1875
+ /* istanbul ignore next -- @preserve */ if (!modelElement) {
1876
+ continue;
1877
+ }
1878
+ const position = editor.model.createPositionAt(modelElement, 0);
1879
+ const newRange = editor.model.schema.getNearestSelectionRange(position, 'forward');
1880
+ editor.model.change((writer)=>{
1881
+ writer.setSelection(newRange);
1882
+ });
1883
+ return true;
1884
+ }
1885
+ }
1886
+ return false;
1887
+ }
1888
+ /**
1889
+ * Updates the document selection so that it selects first ancestor widget.
1890
+ */ _selectAncestorWidget() {
1891
+ const editor = this.editor;
1892
+ const mapper = editor.editing.mapper;
1893
+ const selection = editor.editing.view.document.selection;
1894
+ const positionParent = selection.getFirstPosition().parent;
1895
+ const positionParentElement = positionParent.is('$text') ? positionParent.parent : positionParent;
1896
+ const viewElement = positionParentElement.findAncestor(isWidget);
1897
+ if (!viewElement) {
1898
+ return false;
1899
+ }
1900
+ const modelElement = mapper.toModelElement(viewElement);
1901
+ /* istanbul ignore next -- @preserve */ if (!modelElement) {
1902
+ return false;
1903
+ }
1904
+ editor.model.change((writer)=>{
1905
+ writer.setSelection(modelElement, 'on');
1906
+ });
1907
+ return true;
1908
+ }
1909
+ constructor(){
1910
+ super(...arguments);
1911
+ /**
1912
+ * Holds previously selected widgets.
1913
+ */ this._previouslySelected = new Set();
1914
+ }
1915
+ }
1916
+ /**
1917
+ * Returns `true` when element is a nested editable or is placed inside one.
1918
+ */ function isInsideNestedEditable(element) {
1919
+ let currentElement = element;
1920
+ while(currentElement){
1921
+ if (currentElement.is('editableElement') && !currentElement.is('rootElement')) {
1922
+ return true;
1923
+ }
1924
+ // Click on nested widget should select it.
1925
+ if (isWidget(currentElement)) {
1926
+ return false;
1927
+ }
1928
+ currentElement = currentElement.parent;
1929
+ }
1930
+ return false;
1931
+ }
1932
+ /**
1933
+ * Checks whether the specified `element` is a child of the `parent` element.
1934
+ *
1935
+ * @param element An element to check.
1936
+ * @param parent A parent for the element.
1937
+ */ function isChild(element, parent) {
1938
+ if (!parent) {
1939
+ return false;
1940
+ }
1941
+ return Array.from(element.getAncestors()).includes(parent);
1942
+ }
1943
+ /**
1944
+ * Returns nearest text block ancestor.
1945
+ */ function findTextBlockAncestor(modelElement, schema) {
1946
+ for (const element of modelElement.getAncestors({
1947
+ includeSelf: true,
1948
+ parentFirst: true
1949
+ })){
1950
+ if (schema.checkChild(element, '$text')) {
1951
+ return element;
1952
+ }
1953
+ // Do not go beyond nested editable.
1954
+ if (schema.isLimit(element) && !schema.isObject(element)) {
1955
+ break;
1956
+ }
1957
+ }
1958
+ return null;
1959
+ }
1960
+ /**
1961
+ * Returns next text block where could put selection.
1962
+ */ function findNextTextBlock(position, schema) {
1963
+ const treeWalker = new TreeWalker({
1964
+ startPosition: position
1965
+ });
1966
+ for (const { item } of treeWalker){
1967
+ if (schema.isLimit(item) || !item.is('element')) {
1968
+ return null;
1969
+ }
1970
+ if (schema.checkChild(item, '$text')) {
1971
+ return item;
1972
+ }
1973
+ }
1974
+ return null;
1975
+ }
1976
+
1977
+ class WidgetToolbarRepository extends Plugin {
1978
+ /**
1979
+ * @inheritDoc
1980
+ */ static get requires() {
1981
+ return [
1982
+ ContextualBalloon
1983
+ ];
1984
+ }
1985
+ /**
1986
+ * @inheritDoc
1987
+ */ static get pluginName() {
1988
+ return 'WidgetToolbarRepository';
1989
+ }
1990
+ /**
1991
+ * @inheritDoc
1992
+ */ init() {
1993
+ const editor = this.editor;
1994
+ // Disables the default balloon toolbar for all widgets.
1995
+ if (editor.plugins.has('BalloonToolbar')) {
1996
+ const balloonToolbar = editor.plugins.get('BalloonToolbar');
1997
+ this.listenTo(balloonToolbar, 'show', (evt)=>{
1998
+ if (isWidgetSelected(editor.editing.view.document.selection)) {
1999
+ evt.stop();
2000
+ }
2001
+ }, {
2002
+ priority: 'high'
2003
+ });
2004
+ }
2005
+ this._balloon = this.editor.plugins.get('ContextualBalloon');
2006
+ this.on('change:isEnabled', ()=>{
2007
+ this._updateToolbarsVisibility();
2008
+ });
2009
+ this.listenTo(editor.ui, 'update', ()=>{
2010
+ this._updateToolbarsVisibility();
2011
+ });
2012
+ // UI#update is not fired after focus is back in editor, we need to check if balloon panel should be visible.
2013
+ this.listenTo(editor.ui.focusTracker, 'change:isFocused', ()=>{
2014
+ this._updateToolbarsVisibility();
2015
+ }, {
2016
+ priority: 'low'
2017
+ });
2018
+ }
2019
+ destroy() {
2020
+ super.destroy();
2021
+ for (const toolbarConfig of this._toolbarDefinitions.values()){
2022
+ toolbarConfig.view.destroy();
2023
+ }
2024
+ }
2025
+ /**
2026
+ * Registers toolbar in the WidgetToolbarRepository. It renders it in the `ContextualBalloon` based on the value of the invoked
2027
+ * `getRelatedElement` function. Toolbar items are gathered from `items` array.
2028
+ * The balloon's CSS class is by default `ck-toolbar-container` and may be override with the `balloonClassName` option.
2029
+ *
2030
+ * Note: This method should be called in the {@link module:core/plugin~PluginInterface#afterInit `Plugin#afterInit()`}
2031
+ * callback (or later) to make sure that the given toolbar items were already registered by other plugins.
2032
+ *
2033
+ * @param toolbarId An id for the toolbar. Used to
2034
+ * @param options.ariaLabel Label used by assistive technologies to describe this toolbar element.
2035
+ * @param options.items Array of toolbar items.
2036
+ * @param options.getRelatedElement Callback which returns an element the toolbar should be attached to.
2037
+ * @param options.balloonClassName CSS class for the widget balloon.
2038
+ */ register(toolbarId, { ariaLabel, items, getRelatedElement, balloonClassName = 'ck-toolbar-container' }) {
2039
+ // Trying to register a toolbar without any item.
2040
+ if (!items.length) {
2041
+ /**
2042
+ * When {@link module:widget/widgettoolbarrepository~WidgetToolbarRepository#register registering} a new widget toolbar, you
2043
+ * need to provide a non-empty array with the items that will be inserted into the toolbar.
2044
+ *
2045
+ * If you see this error when integrating the editor, you likely forgot to configure one of the widget toolbars.
2046
+ *
2047
+ * See for instance:
2048
+ *
2049
+ * * {@link module:table/tableconfig~TableConfig#contentToolbar `config.table.contentToolbar`}
2050
+ * * {@link module:image/imageconfig~ImageConfig#toolbar `config.image.toolbar`}
2051
+ *
2052
+ * @error widget-toolbar-no-items
2053
+ * @param toolbarId The id of the toolbar that has not been configured correctly.
2054
+ */ logWarning('widget-toolbar-no-items', {
2055
+ toolbarId
2056
+ });
2057
+ return;
2058
+ }
2059
+ const editor = this.editor;
2060
+ const t = editor.t;
2061
+ const toolbarView = new ToolbarView(editor.locale);
2062
+ toolbarView.ariaLabel = ariaLabel || t('Widget toolbar');
2063
+ if (this._toolbarDefinitions.has(toolbarId)) {
2064
+ /**
2065
+ * Toolbar with the given id was already added.
2066
+ *
2067
+ * @error widget-toolbar-duplicated
2068
+ * @param toolbarId Toolbar id.
2069
+ */ throw new CKEditorError('widget-toolbar-duplicated', this, {
2070
+ toolbarId
2071
+ });
2072
+ }
2073
+ const toolbarDefinition = {
2074
+ view: toolbarView,
2075
+ getRelatedElement,
2076
+ balloonClassName,
2077
+ itemsConfig: items,
2078
+ initialized: false
2079
+ };
2080
+ // Register the toolbar so it becomes available for Alt+F10 and Esc navigation.
2081
+ editor.ui.addToolbar(toolbarView, {
2082
+ isContextual: true,
2083
+ beforeFocus: ()=>{
2084
+ const relatedElement = getRelatedElement(editor.editing.view.document.selection);
2085
+ if (relatedElement) {
2086
+ this._showToolbar(toolbarDefinition, relatedElement);
2087
+ }
2088
+ },
2089
+ afterBlur: ()=>{
2090
+ this._hideToolbar(toolbarDefinition);
2091
+ }
2092
+ });
2093
+ this._toolbarDefinitions.set(toolbarId, toolbarDefinition);
2094
+ }
2095
+ /**
2096
+ * Iterates over stored toolbars and makes them visible or hidden.
2097
+ */ _updateToolbarsVisibility() {
2098
+ let maxRelatedElementDepth = 0;
2099
+ let deepestRelatedElement = null;
2100
+ let deepestToolbarDefinition = null;
2101
+ for (const definition of this._toolbarDefinitions.values()){
2102
+ const relatedElement = definition.getRelatedElement(this.editor.editing.view.document.selection);
2103
+ if (!this.isEnabled || !relatedElement) {
2104
+ if (this._isToolbarInBalloon(definition)) {
2105
+ this._hideToolbar(definition);
2106
+ }
2107
+ } else if (!this.editor.ui.focusTracker.isFocused) {
2108
+ if (this._isToolbarVisible(definition)) {
2109
+ this._hideToolbar(definition);
2110
+ }
2111
+ } else {
2112
+ const relatedElementDepth = relatedElement.getAncestors().length;
2113
+ // Many toolbars can express willingness to be displayed but they do not know about
2114
+ // each other. Figure out which toolbar is deepest in the view tree to decide which
2115
+ // should be displayed. For instance, if a selected image is inside a table cell, display
2116
+ // the ImageToolbar rather than the TableToolbar (#60).
2117
+ if (relatedElementDepth > maxRelatedElementDepth) {
2118
+ maxRelatedElementDepth = relatedElementDepth;
2119
+ deepestRelatedElement = relatedElement;
2120
+ deepestToolbarDefinition = definition;
2121
+ }
2122
+ }
2123
+ }
2124
+ if (deepestToolbarDefinition) {
2125
+ this._showToolbar(deepestToolbarDefinition, deepestRelatedElement);
2126
+ }
2127
+ }
2128
+ /**
2129
+ * Hides the given toolbar.
2130
+ */ _hideToolbar(toolbarDefinition) {
2131
+ this._balloon.remove(toolbarDefinition.view);
2132
+ this.stopListening(this._balloon, 'change:visibleView');
2133
+ }
2134
+ /**
2135
+ * Shows up the toolbar if the toolbar is not visible.
2136
+ * Otherwise, repositions the toolbar's balloon when toolbar's view is the most top view in balloon stack.
2137
+ *
2138
+ * It might happen here that the toolbar's view is under another view. Then do nothing as the other toolbar view
2139
+ * should be still visible after the {@link module:ui/editorui/editorui~EditorUI#event:update}.
2140
+ */ _showToolbar(toolbarDefinition, relatedElement) {
2141
+ if (this._isToolbarVisible(toolbarDefinition)) {
2142
+ repositionContextualBalloon(this.editor, relatedElement);
2143
+ } else if (!this._isToolbarInBalloon(toolbarDefinition)) {
2144
+ if (!toolbarDefinition.initialized) {
2145
+ toolbarDefinition.initialized = true;
2146
+ toolbarDefinition.view.fillFromConfig(toolbarDefinition.itemsConfig, this.editor.ui.componentFactory);
2147
+ }
2148
+ this._balloon.add({
2149
+ view: toolbarDefinition.view,
2150
+ position: getBalloonPositionData(this.editor, relatedElement),
2151
+ balloonClassName: toolbarDefinition.balloonClassName
2152
+ });
2153
+ // Update toolbar position each time stack with toolbar view is switched to visible.
2154
+ // This is in a case target element has changed when toolbar was in invisible stack
2155
+ // e.g. target image was wrapped by a block quote.
2156
+ // See https://github.com/ckeditor/ckeditor5-widget/issues/92.
2157
+ this.listenTo(this._balloon, 'change:visibleView', ()=>{
2158
+ for (const definition of this._toolbarDefinitions.values()){
2159
+ if (this._isToolbarVisible(definition)) {
2160
+ const relatedElement = definition.getRelatedElement(this.editor.editing.view.document.selection);
2161
+ repositionContextualBalloon(this.editor, relatedElement);
2162
+ }
2163
+ }
2164
+ });
2165
+ }
2166
+ }
2167
+ _isToolbarVisible(toolbar) {
2168
+ return this._balloon.visibleView === toolbar.view;
2169
+ }
2170
+ _isToolbarInBalloon(toolbar) {
2171
+ return this._balloon.hasView(toolbar.view);
2172
+ }
2173
+ constructor(){
2174
+ super(...arguments);
2175
+ /**
2176
+ * A map of toolbar definitions.
2177
+ */ this._toolbarDefinitions = new Map();
2178
+ }
2179
+ }
2180
+ function repositionContextualBalloon(editor, relatedElement) {
2181
+ const balloon = editor.plugins.get('ContextualBalloon');
2182
+ const position = getBalloonPositionData(editor, relatedElement);
2183
+ balloon.updatePosition(position);
2184
+ }
2185
+ function getBalloonPositionData(editor, relatedElement) {
2186
+ const editingView = editor.editing.view;
2187
+ const defaultPositions = BalloonPanelView.defaultPositions;
2188
+ return {
2189
+ target: editingView.domConverter.mapViewToDom(relatedElement),
2190
+ positions: [
2191
+ defaultPositions.northArrowSouth,
2192
+ defaultPositions.northArrowSouthWest,
2193
+ defaultPositions.northArrowSouthEast,
2194
+ defaultPositions.southArrowNorth,
2195
+ defaultPositions.southArrowNorthWest,
2196
+ defaultPositions.southArrowNorthEast,
2197
+ defaultPositions.viewportStickyNorth
2198
+ ]
2199
+ };
2200
+ }
2201
+ function isWidgetSelected(selection) {
2202
+ const viewElement = selection.getSelectedElement();
2203
+ return !!(viewElement && isWidget(viewElement));
2204
+ }
2205
+
2206
+ class ResizeState extends ObservableMixin() {
2207
+ /**
2208
+ * The original width (pixels) of the resized object when the resize process was started.
2209
+ */ get originalWidth() {
2210
+ return this._originalWidth;
2211
+ }
2212
+ /**
2213
+ * The original height (pixels) of the resized object when the resize process was started.
2214
+ */ get originalHeight() {
2215
+ return this._originalHeight;
2216
+ }
2217
+ /**
2218
+ * The original width (percents) of the resized object when the resize process was started.
2219
+ */ get originalWidthPercents() {
2220
+ return this._originalWidthPercents;
2221
+ }
2222
+ /**
2223
+ * A width to height ratio of the resized image.
2224
+ */ get aspectRatio() {
2225
+ return this._aspectRatio;
2226
+ }
2227
+ /**
2228
+ *
2229
+ * @param domResizeHandle The handle used to calculate the reference point.
2230
+ */ begin(domResizeHandle, domHandleHost, domResizeHost) {
2231
+ const clientRect = new Rect(domHandleHost);
2232
+ this.activeHandlePosition = getHandlePosition(domResizeHandle);
2233
+ this._referenceCoordinates = getAbsoluteBoundaryPoint(domHandleHost, getOppositePosition(this.activeHandlePosition));
2234
+ this._originalWidth = clientRect.width;
2235
+ this._originalHeight = clientRect.height;
2236
+ this._aspectRatio = clientRect.width / clientRect.height;
2237
+ const widthStyle = domResizeHost.style.width;
2238
+ if (widthStyle && widthStyle.match(/^\d+(\.\d*)?%$/)) {
2239
+ this._originalWidthPercents = parseFloat(widthStyle);
2240
+ } else {
2241
+ this._originalWidthPercents = calculateResizeHostPercentageWidth(domResizeHost, clientRect);
2242
+ }
2243
+ }
2244
+ update(newSize) {
2245
+ this.proposedWidth = newSize.width;
2246
+ this.proposedHeight = newSize.height;
2247
+ this.proposedWidthPercents = newSize.widthPercents;
2248
+ this.proposedHandleHostWidth = newSize.handleHostWidth;
2249
+ this.proposedHandleHostHeight = newSize.handleHostHeight;
2250
+ }
2251
+ /**
2252
+ * @param options Resizer options.
2253
+ */ constructor(options){
2254
+ super();
2255
+ this.set('activeHandlePosition', null);
2256
+ this.set('proposedWidthPercents', null);
2257
+ this.set('proposedWidth', null);
2258
+ this.set('proposedHeight', null);
2259
+ this.set('proposedHandleHostWidth', null);
2260
+ this.set('proposedHandleHostHeight', null);
2261
+ this._options = options;
2262
+ this._referenceCoordinates = null;
2263
+ }
2264
+ }
2265
+ /**
2266
+ * Returns coordinates of the top-left corner of an element, relative to the document's top-left corner.
2267
+ *
2268
+ * @param resizerPosition The position of the resize handle, e.g. `"top-left"`, `"bottom-right"`.
2269
+ */ function getAbsoluteBoundaryPoint(element, resizerPosition) {
2270
+ const elementRect = new Rect(element);
2271
+ const positionParts = resizerPosition.split('-');
2272
+ const ret = {
2273
+ x: positionParts[1] == 'right' ? elementRect.right : elementRect.left,
2274
+ y: positionParts[0] == 'bottom' ? elementRect.bottom : elementRect.top
2275
+ };
2276
+ ret.x += element.ownerDocument.defaultView.scrollX;
2277
+ ret.y += element.ownerDocument.defaultView.scrollY;
2278
+ return ret;
2279
+ }
2280
+ /**
2281
+ * @param resizerPosition The expected resizer position, like `"top-left"`, `"bottom-right"`.
2282
+ * @returns A prefixed HTML class name for the resizer element.
2283
+ */ function getResizerHandleClass(resizerPosition) {
2284
+ return `ck-widget__resizer__handle-${resizerPosition}`;
2285
+ }
2286
+ /**
2287
+ * Determines the position of a given resize handle.
2288
+ *
2289
+ * @param domHandle Handle used to calculate the reference point.
2290
+ * @returns Returns a string like `"top-left"` or `undefined` if not matched.
2291
+ */ function getHandlePosition(domHandle) {
2292
+ const resizerPositions = [
2293
+ 'top-left',
2294
+ 'top-right',
2295
+ 'bottom-right',
2296
+ 'bottom-left'
2297
+ ];
2298
+ for (const position of resizerPositions){
2299
+ if (domHandle.classList.contains(getResizerHandleClass(position))) {
2300
+ return position;
2301
+ }
2302
+ }
2303
+ }
2304
+ /**
2305
+ * @param position Like `"top-left"`.
2306
+ * @returns Inverted `position`, e.g. it returns `"bottom-right"` if `"top-left"` was given as `position`.
2307
+ */ function getOppositePosition(position) {
2308
+ const parts = position.split('-');
2309
+ const replacements = {
2310
+ top: 'bottom',
2311
+ bottom: 'top',
2312
+ left: 'right',
2313
+ right: 'left'
2314
+ };
2315
+ return `${replacements[parts[0]]}-${replacements[parts[1]]}`;
2316
+ }
2317
+
2318
+ class SizeView extends View {
2319
+ /**
2320
+ * A method used for binding the `SizeView` instance properties to the `ResizeState` instance observable properties.
2321
+ *
2322
+ * @internal
2323
+ * @param options An object defining the resizer options, used for setting the proper size label.
2324
+ * @param resizeState The `ResizeState` class instance, used for keeping the `SizeView` state up to date.
2325
+ */ _bindToState(options, resizeState) {
2326
+ this.bind('_isVisible').to(resizeState, 'proposedWidth', resizeState, 'proposedHeight', (width, height)=>width !== null && height !== null);
2327
+ this.bind('_label').to(resizeState, 'proposedHandleHostWidth', resizeState, 'proposedHandleHostHeight', resizeState, 'proposedWidthPercents', (width, height, widthPercents)=>{
2328
+ if (options.unit === 'px') {
2329
+ return `${width}×${height}`;
2330
+ } else {
2331
+ return `${widthPercents}%`;
2332
+ }
2333
+ });
2334
+ this.bind('_viewPosition').to(resizeState, 'activeHandlePosition', resizeState, 'proposedHandleHostWidth', resizeState, 'proposedHandleHostHeight', // If the widget is too small to contain the size label, display the label above.
2335
+ (position, width, height)=>width < 50 || height < 50 ? 'above-center' : position);
2336
+ }
2337
+ /**
2338
+ * A method used for cleaning up. It removes the bindings and hides the view.
2339
+ *
2340
+ * @internal
2341
+ */ _dismiss() {
2342
+ this.unbind();
2343
+ this._isVisible = false;
2344
+ }
2345
+ constructor(){
2346
+ super();
2347
+ const bind = this.bindTemplate;
2348
+ this.setTemplate({
2349
+ tag: 'div',
2350
+ attributes: {
2351
+ class: [
2352
+ 'ck',
2353
+ 'ck-size-view',
2354
+ bind.to('_viewPosition', (value)=>value ? `ck-orientation-${value}` : '')
2355
+ ],
2356
+ style: {
2357
+ display: bind.if('_isVisible', 'none', (visible)=>!visible)
2358
+ }
2359
+ },
2360
+ children: [
2361
+ {
2362
+ text: bind.to('_label')
2363
+ }
2364
+ ]
2365
+ });
2366
+ }
2367
+ }
2368
+
2369
+ class Resizer extends ObservableMixin() {
2370
+ /**
2371
+ * Stores the state of the resizable host geometry, such as the original width, the currently proposed height, etc.
2372
+ *
2373
+ * Note that a new state is created for each resize transaction.
2374
+ */ get state() {
2375
+ return this._state;
2376
+ }
2377
+ /**
2378
+ * Makes resizer visible in the UI.
2379
+ */ show() {
2380
+ const editingView = this._options.editor.editing.view;
2381
+ editingView.change((writer)=>{
2382
+ writer.removeClass('ck-hidden', this._viewResizerWrapper);
2383
+ });
2384
+ }
2385
+ /**
2386
+ * Hides resizer in the UI.
2387
+ */ hide() {
2388
+ const editingView = this._options.editor.editing.view;
2389
+ editingView.change((writer)=>{
2390
+ writer.addClass('ck-hidden', this._viewResizerWrapper);
2391
+ });
2392
+ }
2393
+ /**
2394
+ * Attaches the resizer to the DOM.
2395
+ */ attach() {
2396
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
2397
+ const that = this;
2398
+ const widgetElement = this._options.viewElement;
2399
+ const editingView = this._options.editor.editing.view;
2400
+ editingView.change((writer)=>{
2401
+ const viewResizerWrapper = writer.createUIElement('div', {
2402
+ class: 'ck ck-reset_all ck-widget__resizer'
2403
+ }, function(domDocument) {
2404
+ const domElement = this.toDomElement(domDocument);
2405
+ that._appendHandles(domElement);
2406
+ that._appendSizeUI(domElement);
2407
+ return domElement;
2408
+ });
2409
+ // Append the resizer wrapper to the widget's wrapper.
2410
+ writer.insert(writer.createPositionAt(widgetElement, 'end'), viewResizerWrapper);
2411
+ writer.addClass('ck-widget_with-resizer', widgetElement);
2412
+ this._viewResizerWrapper = viewResizerWrapper;
2413
+ if (!this.isVisible) {
2414
+ this.hide();
2415
+ }
2416
+ });
2417
+ this.on('change:isVisible', ()=>{
2418
+ if (this.isVisible) {
2419
+ this.show();
2420
+ this.redraw();
2421
+ } else {
2422
+ this.hide();
2423
+ }
2424
+ });
2425
+ }
2426
+ /**
2427
+ * Starts the resizing process.
2428
+ *
2429
+ * Creates a new {@link #state} for the current process.
2430
+ *
2431
+ * @fires begin
2432
+ * @param domResizeHandle Clicked handle.
2433
+ */ begin(domResizeHandle) {
2434
+ this._state = new ResizeState(this._options);
2435
+ this._sizeView._bindToState(this._options, this.state);
2436
+ this._initialViewWidth = this._options.viewElement.getStyle('width');
2437
+ this.state.begin(domResizeHandle, this._getHandleHost(), this._getResizeHost());
2438
+ }
2439
+ /**
2440
+ * Updates the proposed size based on `domEventData`.
2441
+ *
2442
+ * @fires updateSize
2443
+ */ updateSize(domEventData) {
2444
+ const newSize = this._proposeNewSize(domEventData);
2445
+ const editingView = this._options.editor.editing.view;
2446
+ editingView.change((writer)=>{
2447
+ const unit = this._options.unit || '%';
2448
+ const newWidth = (unit === '%' ? newSize.widthPercents : newSize.width) + unit;
2449
+ writer.setStyle('width', newWidth, this._options.viewElement);
2450
+ });
2451
+ // Get an actual image width, and:
2452
+ // * reflect this size to the resize wrapper
2453
+ // * apply this **real** size to the state
2454
+ const domHandleHost = this._getHandleHost();
2455
+ const domHandleHostRect = new Rect(domHandleHost);
2456
+ const handleHostWidth = Math.round(domHandleHostRect.width);
2457
+ const handleHostHeight = Math.round(domHandleHostRect.height);
2458
+ // Handle max-width limitation.
2459
+ const domResizeHostRect = new Rect(domHandleHost);
2460
+ newSize.width = Math.round(domResizeHostRect.width);
2461
+ newSize.height = Math.round(domResizeHostRect.height);
2462
+ this.redraw(domHandleHostRect);
2463
+ this.state.update({
2464
+ ...newSize,
2465
+ handleHostWidth,
2466
+ handleHostHeight
2467
+ });
2468
+ }
2469
+ /**
2470
+ * Applies the geometry proposed with the resizer.
2471
+ *
2472
+ * @fires commit
2473
+ */ commit() {
2474
+ const unit = this._options.unit || '%';
2475
+ const newValue = (unit === '%' ? this.state.proposedWidthPercents : this.state.proposedWidth) + unit;
2476
+ // Both cleanup and onCommit callback are very likely to make view changes. Ensure that it is made in a single step.
2477
+ this._options.editor.editing.view.change(()=>{
2478
+ this._cleanup();
2479
+ this._options.onCommit(newValue);
2480
+ });
2481
+ }
2482
+ /**
2483
+ * Cancels and rejects the proposed resize dimensions, hiding the UI.
2484
+ *
2485
+ * @fires cancel
2486
+ */ cancel() {
2487
+ this._cleanup();
2488
+ }
2489
+ /**
2490
+ * Destroys the resizer.
2491
+ */ destroy() {
2492
+ this.cancel();
2493
+ }
2494
+ /**
2495
+ * Redraws the resizer.
2496
+ *
2497
+ * @param handleHostRect Handle host rectangle might be given to improve performance.
2498
+ */ redraw(handleHostRect) {
2499
+ const domWrapper = this._domResizerWrapper;
2500
+ // Refresh only if resizer exists in the DOM.
2501
+ if (!existsInDom(domWrapper)) {
2502
+ return;
2503
+ }
2504
+ const widgetWrapper = domWrapper.parentElement;
2505
+ const handleHost = this._getHandleHost();
2506
+ const resizerWrapper = this._viewResizerWrapper;
2507
+ const currentDimensions = [
2508
+ resizerWrapper.getStyle('width'),
2509
+ resizerWrapper.getStyle('height'),
2510
+ resizerWrapper.getStyle('left'),
2511
+ resizerWrapper.getStyle('top')
2512
+ ];
2513
+ let newDimensions;
2514
+ if (widgetWrapper.isSameNode(handleHost)) {
2515
+ const clientRect = handleHostRect || new Rect(handleHost);
2516
+ newDimensions = [
2517
+ clientRect.width + 'px',
2518
+ clientRect.height + 'px',
2519
+ undefined,
2520
+ undefined
2521
+ ];
2522
+ } else {
2523
+ newDimensions = [
2524
+ handleHost.offsetWidth + 'px',
2525
+ handleHost.offsetHeight + 'px',
2526
+ handleHost.offsetLeft + 'px',
2527
+ handleHost.offsetTop + 'px'
2528
+ ];
2529
+ }
2530
+ // Make changes to the view only if the resizer should actually get new dimensions.
2531
+ // Otherwise, if View#change() was always called, this would cause EditorUI#update
2532
+ // loops because the WidgetResize plugin listens to EditorUI#update and updates
2533
+ // the resizer.
2534
+ // https://github.com/ckeditor/ckeditor5/issues/7633
2535
+ if (compareArrays(currentDimensions, newDimensions) !== 'same') {
2536
+ this._options.editor.editing.view.change((writer)=>{
2537
+ writer.setStyle({
2538
+ width: newDimensions[0],
2539
+ height: newDimensions[1],
2540
+ left: newDimensions[2],
2541
+ top: newDimensions[3]
2542
+ }, resizerWrapper);
2543
+ });
2544
+ }
2545
+ }
2546
+ containsHandle(domElement) {
2547
+ return this._domResizerWrapper.contains(domElement);
2548
+ }
2549
+ static isResizeHandle(domElement) {
2550
+ return domElement.classList.contains('ck-widget__resizer__handle');
2551
+ }
2552
+ /**
2553
+ * Cleans up the context state.
2554
+ */ _cleanup() {
2555
+ this._sizeView._dismiss();
2556
+ const editingView = this._options.editor.editing.view;
2557
+ editingView.change((writer)=>{
2558
+ writer.setStyle('width', this._initialViewWidth, this._options.viewElement);
2559
+ });
2560
+ }
2561
+ /**
2562
+ * Calculates the proposed size as the resize handles are dragged.
2563
+ *
2564
+ * @param domEventData Event data that caused the size update request. It should be used to calculate the proposed size.
2565
+ */ _proposeNewSize(domEventData) {
2566
+ const state = this.state;
2567
+ const currentCoordinates = extractCoordinates(domEventData);
2568
+ const isCentered = this._options.isCentered ? this._options.isCentered(this) : true;
2569
+ // Enlargement defines how much the resize host has changed in a given axis. Naturally it could be a negative number
2570
+ // meaning that it has been shrunk.
2571
+ //
2572
+ // +----------------+--+
2573
+ // | | |
2574
+ // | img | |
2575
+ // | /handle host | |
2576
+ // +----------------+ | ^
2577
+ // | | | - enlarge y
2578
+ // +-------------------+ v
2579
+ // <-->
2580
+ // enlarge x
2581
+ const enlargement = {
2582
+ x: state._referenceCoordinates.x - (currentCoordinates.x + state.originalWidth),
2583
+ y: currentCoordinates.y - state.originalHeight - state._referenceCoordinates.y
2584
+ };
2585
+ if (isCentered && state.activeHandlePosition.endsWith('-right')) {
2586
+ enlargement.x = currentCoordinates.x - (state._referenceCoordinates.x + state.originalWidth);
2587
+ }
2588
+ // Objects needs to be resized twice as much in horizontal axis if centered, since enlargement is counted from
2589
+ // one resized corner to your cursor. It needs to be duplicated to compensate for the other side too.
2590
+ if (isCentered) {
2591
+ enlargement.x *= 2;
2592
+ }
2593
+ // const resizeHost = this._getResizeHost();
2594
+ // The size proposed by the user. It does not consider the aspect ratio.
2595
+ let width = Math.abs(state.originalWidth + enlargement.x);
2596
+ let height = Math.abs(state.originalHeight + enlargement.y);
2597
+ // Dominant determination must take the ratio into account.
2598
+ const dominant = width / state.aspectRatio > height ? 'width' : 'height';
2599
+ if (dominant == 'width') {
2600
+ height = width / state.aspectRatio;
2601
+ } else {
2602
+ width = height * state.aspectRatio;
2603
+ }
2604
+ return {
2605
+ width: Math.round(width),
2606
+ height: Math.round(height),
2607
+ widthPercents: Math.min(Math.round(state.originalWidthPercents / state.originalWidth * width * 100) / 100, 100)
2608
+ };
2609
+ }
2610
+ /**
2611
+ * Obtains the resize host.
2612
+ *
2613
+ * Resize host is an object that receives dimensions which are the result of resizing.
2614
+ */ _getResizeHost() {
2615
+ const widgetWrapper = this._domResizerWrapper.parentElement;
2616
+ return this._options.getResizeHost(widgetWrapper);
2617
+ }
2618
+ /**
2619
+ * Obtains the handle host.
2620
+ *
2621
+ * Handle host is an object that the handles are aligned to.
2622
+ *
2623
+ * Handle host will not always be an entire widget itself. Take an image as an example. The image widget
2624
+ * contains an image and a caption. Only the image should be surrounded with handles.
2625
+ */ _getHandleHost() {
2626
+ const widgetWrapper = this._domResizerWrapper.parentElement;
2627
+ return this._options.getHandleHost(widgetWrapper);
2628
+ }
2629
+ /**
2630
+ * DOM container of the entire resize UI.
2631
+ *
2632
+ * Note that this property will have a value only after the element bound with the resizer is rendered
2633
+ * (otherwise `null`).
2634
+ */ get _domResizerWrapper() {
2635
+ return this._options.editor.editing.view.domConverter.mapViewToDom(this._viewResizerWrapper);
2636
+ }
2637
+ /**
2638
+ * Renders the resize handles in the DOM.
2639
+ *
2640
+ * @param domElement The resizer wrapper.
2641
+ */ _appendHandles(domElement) {
2642
+ const resizerPositions = [
2643
+ 'top-left',
2644
+ 'top-right',
2645
+ 'bottom-right',
2646
+ 'bottom-left'
2647
+ ];
2648
+ for (const currentPosition of resizerPositions){
2649
+ domElement.appendChild(new Template({
2650
+ tag: 'div',
2651
+ attributes: {
2652
+ class: `ck-widget__resizer__handle ${getResizerClass(currentPosition)}`
2653
+ }
2654
+ }).render());
2655
+ }
2656
+ }
2657
+ /**
2658
+ * Sets up the {@link #_sizeView} property and adds it to the passed `domElement`.
2659
+ */ _appendSizeUI(domElement) {
2660
+ this._sizeView = new SizeView();
2661
+ // Make sure icon#element is rendered before passing to appendChild().
2662
+ this._sizeView.render();
2663
+ domElement.appendChild(this._sizeView.element);
2664
+ }
2665
+ /**
2666
+ * @param options Resizer options.
2667
+ */ constructor(options){
2668
+ super();
2669
+ /**
2670
+ * A wrapper that is controlled by the resizer. This is usually a widget element.
2671
+ */ this._viewResizerWrapper = null;
2672
+ this._options = options;
2673
+ this.set('isEnabled', true);
2674
+ this.set('isSelected', false);
2675
+ this.bind('isVisible').to(this, 'isEnabled', this, 'isSelected', (isEnabled, isSelected)=>isEnabled && isSelected);
2676
+ this.decorate('begin');
2677
+ this.decorate('cancel');
2678
+ this.decorate('commit');
2679
+ this.decorate('updateSize');
2680
+ this.on('commit', (event)=>{
2681
+ // State might not be initialized yet. In this case, prevent further handling and make sure that the resizer is
2682
+ // cleaned up (#5195).
2683
+ if (!this.state.proposedWidth && !this.state.proposedWidthPercents) {
2684
+ this._cleanup();
2685
+ event.stop();
2686
+ }
2687
+ }, {
2688
+ priority: 'high'
2689
+ });
2690
+ }
2691
+ }
2692
+ /**
2693
+ * @param resizerPosition Expected resizer position like `"top-left"`, `"bottom-right"`.
2694
+ * @returns A prefixed HTML class name for the resizer element
2695
+ */ function getResizerClass(resizerPosition) {
2696
+ return `ck-widget__resizer__handle-${resizerPosition}`;
2697
+ }
2698
+ function extractCoordinates(event) {
2699
+ return {
2700
+ x: event.pageX,
2701
+ y: event.pageY
2702
+ };
2703
+ }
2704
+ function existsInDom(element) {
2705
+ return element && element.ownerDocument && element.ownerDocument.contains(element);
2706
+ }
2707
+
2708
+ class WidgetResize extends Plugin {
2709
+ /**
2710
+ * @inheritDoc
2711
+ */ static get pluginName() {
2712
+ return 'WidgetResize';
2713
+ }
2714
+ /**
2715
+ * @inheritDoc
2716
+ */ init() {
2717
+ const editing = this.editor.editing;
2718
+ const domDocument = global.window.document;
2719
+ this.set('selectedResizer', null);
2720
+ this.set('_activeResizer', null);
2721
+ editing.view.addObserver(MouseObserver);
2722
+ this._observer = new (DomEmitterMixin())();
2723
+ this.listenTo(editing.view.document, 'mousedown', this._mouseDownListener.bind(this), {
2724
+ priority: 'high'
2725
+ });
2726
+ this._observer.listenTo(domDocument, 'mousemove', this._mouseMoveListener.bind(this));
2727
+ this._observer.listenTo(domDocument, 'mouseup', this._mouseUpListener.bind(this));
2728
+ this._redrawSelectedResizerThrottled = throttle(()=>this.redrawSelectedResizer(), 200);
2729
+ // Redrawing on any change of the UI of the editor (including content changes).
2730
+ this.editor.ui.on('update', this._redrawSelectedResizerThrottled);
2731
+ // Remove view widget-resizer mappings for widgets that have been removed from the document.
2732
+ // https://github.com/ckeditor/ckeditor5/issues/10156
2733
+ // https://github.com/ckeditor/ckeditor5/issues/10266
2734
+ this.editor.model.document.on('change', ()=>{
2735
+ for (const [viewElement, resizer] of this._resizers){
2736
+ if (!viewElement.isAttached()) {
2737
+ this._resizers.delete(viewElement);
2738
+ resizer.destroy();
2739
+ }
2740
+ }
2741
+ }, {
2742
+ priority: 'lowest'
2743
+ });
2744
+ // Resizers need to be redrawn upon window resize, because new window might shrink resize host.
2745
+ this._observer.listenTo(global.window, 'resize', this._redrawSelectedResizerThrottled);
2746
+ const viewSelection = this.editor.editing.view.document.selection;
2747
+ viewSelection.on('change', ()=>{
2748
+ const selectedElement = viewSelection.getSelectedElement();
2749
+ const resizer = this.getResizerByViewElement(selectedElement) || null;
2750
+ if (resizer) {
2751
+ this.select(resizer);
2752
+ } else {
2753
+ this.deselect();
2754
+ }
2755
+ });
2756
+ }
2757
+ /**
2758
+ * Redraws the selected resizer if there is any selected resizer and if it is visible.
2759
+ */ redrawSelectedResizer() {
2760
+ if (this.selectedResizer && this.selectedResizer.isVisible) {
2761
+ this.selectedResizer.redraw();
2762
+ }
2763
+ }
2764
+ /**
2765
+ * @inheritDoc
2766
+ */ destroy() {
2767
+ super.destroy();
2768
+ this._observer.stopListening();
2769
+ for (const resizer of this._resizers.values()){
2770
+ resizer.destroy();
2771
+ }
2772
+ this._redrawSelectedResizerThrottled.cancel();
2773
+ }
2774
+ /**
2775
+ * Marks resizer as selected.
2776
+ */ select(resizer) {
2777
+ this.deselect();
2778
+ this.selectedResizer = resizer;
2779
+ this.selectedResizer.isSelected = true;
2780
+ }
2781
+ /**
2782
+ * Deselects currently set resizer.
2783
+ */ deselect() {
2784
+ if (this.selectedResizer) {
2785
+ this.selectedResizer.isSelected = false;
2786
+ }
2787
+ this.selectedResizer = null;
2788
+ }
2789
+ /**
2790
+ * @param options Resizer options.
2791
+ */ attachTo(options) {
2792
+ const resizer = new Resizer(options);
2793
+ const plugins = this.editor.plugins;
2794
+ resizer.attach();
2795
+ if (plugins.has('WidgetToolbarRepository')) {
2796
+ // Hiding widget toolbar to improve the performance
2797
+ // (https://github.com/ckeditor/ckeditor5-widget/pull/112#issuecomment-564528765).
2798
+ const widgetToolbarRepository = plugins.get('WidgetToolbarRepository');
2799
+ resizer.on('begin', ()=>{
2800
+ widgetToolbarRepository.forceDisabled('resize');
2801
+ }, {
2802
+ priority: 'lowest'
2803
+ });
2804
+ resizer.on('cancel', ()=>{
2805
+ widgetToolbarRepository.clearForceDisabled('resize');
2806
+ }, {
2807
+ priority: 'highest'
2808
+ });
2809
+ resizer.on('commit', ()=>{
2810
+ widgetToolbarRepository.clearForceDisabled('resize');
2811
+ }, {
2812
+ priority: 'highest'
2813
+ });
2814
+ }
2815
+ this._resizers.set(options.viewElement, resizer);
2816
+ const viewSelection = this.editor.editing.view.document.selection;
2817
+ const selectedElement = viewSelection.getSelectedElement();
2818
+ // If the element the resizer is created for is currently focused, it should become visible.
2819
+ if (this.getResizerByViewElement(selectedElement) == resizer) {
2820
+ this.select(resizer);
2821
+ }
2822
+ return resizer;
2823
+ }
2824
+ /**
2825
+ * Returns a resizer created for a given view element (widget element).
2826
+ *
2827
+ * @param viewElement View element associated with the resizer.
2828
+ */ getResizerByViewElement(viewElement) {
2829
+ return this._resizers.get(viewElement);
2830
+ }
2831
+ /**
2832
+ * Returns a resizer that contains a given resize handle.
2833
+ */ _getResizerByHandle(domResizeHandle) {
2834
+ for (const resizer of this._resizers.values()){
2835
+ if (resizer.containsHandle(domResizeHandle)) {
2836
+ return resizer;
2837
+ }
2838
+ }
2839
+ }
2840
+ /**
2841
+ * @param domEventData Native DOM event.
2842
+ */ _mouseDownListener(event, domEventData) {
2843
+ const resizeHandle = domEventData.domTarget;
2844
+ if (!Resizer.isResizeHandle(resizeHandle)) {
2845
+ return;
2846
+ }
2847
+ this._activeResizer = this._getResizerByHandle(resizeHandle) || null;
2848
+ if (this._activeResizer) {
2849
+ this._activeResizer.begin(resizeHandle);
2850
+ // Do not call other events when resizing. See: #6755.
2851
+ event.stop();
2852
+ domEventData.preventDefault();
2853
+ }
2854
+ }
2855
+ /**
2856
+ * @param domEventData Native DOM event.
2857
+ */ _mouseMoveListener(event, domEventData) {
2858
+ if (this._activeResizer) {
2859
+ this._activeResizer.updateSize(domEventData);
2860
+ }
2861
+ }
2862
+ _mouseUpListener() {
2863
+ if (this._activeResizer) {
2864
+ this._activeResizer.commit();
2865
+ this._activeResizer = null;
2866
+ }
2867
+ }
2868
+ constructor(){
2869
+ super(...arguments);
2870
+ /**
2871
+ * A map of resizers created using this plugin instance.
2872
+ */ this._resizers = new Map();
2873
+ }
2874
+ }
2875
+
2876
+ export { WIDGET_CLASS_NAME, WIDGET_SELECTED_CLASS_NAME, Widget, WidgetResize, WidgetToolbarRepository, WidgetTypeAround, calculateResizeHostAncestorWidth, calculateResizeHostPercentageWidth, findOptimalInsertionRange, getLabel, isWidget, setHighlightHandling, setLabel, toWidget, toWidgetEditable, viewToModelPositionOutsideModelElement };
2877
+ //# sourceMappingURL=index.js.map