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

Sign up to get free protection for your applications and to get access to all the features.
package/dist/index.js ADDED
@@ -0,0 +1,2979 @@
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