@ckeditor/ckeditor5-widget 41.3.0-alpha.1 → 41.3.0-alpha.2

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