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