@ckeditor/ckeditor5-widget 0.0.0-internal-20241017.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @ckeditor/ckeditor5-widget might be problematic. Click here for more details.

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