@ckeditor/ckeditor5-widget 41.3.1 → 41.4.0

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