@ckeditor/ckeditor5-widget 39.0.1 → 40.0.0
Sign up to get free protection for your applications and to get access to all the features.
- package/CHANGELOG.md +1 -1
- package/LICENSE.md +1 -1
- package/README.md +3 -3
- package/lang/translations/ar.po +1 -0
- package/lang/translations/az.po +1 -0
- package/lang/translations/bg.po +1 -0
- package/lang/translations/bn.po +1 -0
- package/lang/translations/ca.po +1 -0
- package/lang/translations/cs.po +1 -0
- package/lang/translations/da.po +1 -0
- package/lang/translations/de-ch.po +1 -0
- package/lang/translations/de.po +1 -0
- package/lang/translations/el.po +1 -0
- package/lang/translations/en-au.po +1 -0
- package/lang/translations/en.po +1 -0
- package/lang/translations/es.po +1 -0
- package/lang/translations/et.po +1 -0
- package/lang/translations/fa.po +1 -0
- package/lang/translations/fi.po +1 -0
- package/lang/translations/fr.po +1 -0
- package/lang/translations/gl.po +1 -0
- package/lang/translations/he.po +1 -0
- package/lang/translations/hi.po +1 -0
- package/lang/translations/hr.po +1 -0
- package/lang/translations/hu.po +1 -0
- package/lang/translations/id.po +1 -0
- package/lang/translations/it.po +1 -0
- package/lang/translations/ja.po +1 -0
- package/lang/translations/ko.po +1 -0
- package/lang/translations/ku.po +1 -0
- package/lang/translations/lt.po +1 -0
- package/lang/translations/lv.po +1 -0
- package/lang/translations/ms.po +1 -0
- package/lang/translations/nl.po +1 -0
- package/lang/translations/no.po +1 -0
- package/lang/translations/pl.po +1 -0
- package/lang/translations/pt-br.po +1 -0
- package/lang/translations/pt.po +1 -0
- package/lang/translations/ro.po +1 -0
- package/lang/translations/ru.po +1 -0
- package/lang/translations/sk.po +1 -0
- package/lang/translations/sq.po +1 -0
- package/lang/translations/sr-latn.po +1 -0
- package/lang/translations/sr.po +1 -0
- package/lang/translations/sv.po +1 -0
- package/lang/translations/th.po +1 -0
- package/lang/translations/tk.po +1 -0
- package/lang/translations/tr.po +1 -0
- package/lang/translations/uk.po +1 -0
- package/lang/translations/ur.po +1 -0
- package/lang/translations/uz.po +1 -0
- package/lang/translations/vi.po +1 -0
- package/lang/translations/zh-cn.po +1 -0
- package/lang/translations/zh.po +1 -0
- package/package.json +7 -11
- package/src/augmentation.d.ts +13 -13
- package/src/augmentation.js +5 -5
- package/src/highlightstack.d.ts +74 -74
- package/src/highlightstack.js +129 -129
- package/src/index.d.ts +13 -13
- package/src/index.js +13 -13
- package/src/utils.d.ts +198 -198
- package/src/utils.js +348 -348
- package/src/verticalnavigation.d.ts +15 -15
- package/src/verticalnavigation.js +196 -196
- package/src/widget.d.ts +91 -91
- package/src/widget.js +380 -380
- package/src/widgetresize/resizer.d.ts +177 -177
- package/src/widgetresize/resizer.js +372 -372
- package/src/widgetresize/resizerstate.d.ts +125 -125
- package/src/widgetresize/resizerstate.js +150 -150
- package/src/widgetresize/sizeview.d.ts +55 -55
- package/src/widgetresize/sizeview.js +63 -63
- package/src/widgetresize.d.ts +125 -125
- package/src/widgetresize.js +188 -188
- package/src/widgettoolbarrepository.d.ts +94 -94
- package/src/widgettoolbarrepository.js +268 -268
- package/src/widgettypearound/utils.d.ts +38 -38
- package/src/widgettypearound/utils.js +52 -52
- package/src/widgettypearound/widgettypearound.d.ts +229 -229
- package/src/widgettypearound/widgettypearound.js +773 -773
@@ -1,773 +1,773 @@
|
|
1
|
-
/**
|
2
|
-
* @license Copyright (c) 2003-2023, 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
|
-
/* global DOMParser */
|
6
|
-
/**
|
7
|
-
* @module widget/widgettypearound/widgettypearound
|
8
|
-
*/
|
9
|
-
import { Plugin } from '@ckeditor/ckeditor5-core';
|
10
|
-
import { Template } from '@ckeditor/ckeditor5-ui';
|
11
|
-
import { Enter } from '@ckeditor/ckeditor5-enter';
|
12
|
-
import { Delete } from '@ckeditor/ckeditor5-typing';
|
13
|
-
import { env, isForwardArrowKeyCode } from '@ckeditor/ckeditor5-utils';
|
14
|
-
import { isTypeAroundWidget, getClosestTypeAroundDomButton, getTypeAroundButtonPosition, getClosestWidgetViewElement, getTypeAroundFakeCaretPosition, TYPE_AROUND_SELECTION_ATTRIBUTE } from './utils';
|
15
|
-
import { isWidget } from '../utils';
|
16
|
-
import returnIcon from '../../theme/icons/return-arrow.svg';
|
17
|
-
import '../../theme/widgettypearound.css';
|
18
|
-
const POSSIBLE_INSERTION_POSITIONS = ['before', 'after'];
|
19
|
-
// Do the SVG parsing once and then clone the result <svg> DOM element for each new button.
|
20
|
-
const RETURN_ARROW_ICON_ELEMENT = new DOMParser().parseFromString(returnIcon, 'image/svg+xml').firstChild;
|
21
|
-
const PLUGIN_DISABLED_EDITING_ROOT_CLASS = 'ck-widget__type-around_disabled';
|
22
|
-
/**
|
23
|
-
* A plugin that allows users to type around widgets where normally it is impossible to place the caret due
|
24
|
-
* to limitations of web browsers. These "tight spots" occur, for instance, before (or after) a widget being
|
25
|
-
* the first (or last) child of its parent or between two block widgets.
|
26
|
-
*
|
27
|
-
* This plugin extends the {@link module:widget/widget~Widget `Widget`} plugin and injects the user interface
|
28
|
-
* with two buttons into each widget instance in the editor. Each of the buttons can be clicked by the
|
29
|
-
* user if the widget is next to the "tight spot". Once clicked, a paragraph is created with the selection anchored
|
30
|
-
* in it so that users can type (or insert content, paste, etc.) straight away.
|
31
|
-
*/
|
32
|
-
export default class WidgetTypeAround extends Plugin {
|
33
|
-
constructor() {
|
34
|
-
super(...arguments);
|
35
|
-
/**
|
36
|
-
* A reference to the model widget element that has the fake caret active
|
37
|
-
* on either side of it. It is later used to remove CSS classes associated with the fake caret
|
38
|
-
* when the widget no longer needs it.
|
39
|
-
*/
|
40
|
-
this._currentFakeCaretModelElement = null;
|
41
|
-
}
|
42
|
-
/**
|
43
|
-
* @inheritDoc
|
44
|
-
*/
|
45
|
-
static get pluginName() {
|
46
|
-
return 'WidgetTypeAround';
|
47
|
-
}
|
48
|
-
/**
|
49
|
-
* @inheritDoc
|
50
|
-
*/
|
51
|
-
static get requires() {
|
52
|
-
return [Enter, Delete];
|
53
|
-
}
|
54
|
-
/**
|
55
|
-
* @inheritDoc
|
56
|
-
*/
|
57
|
-
init() {
|
58
|
-
const editor = this.editor;
|
59
|
-
const editingView = editor.editing.view;
|
60
|
-
// Set a CSS class on the view editing root when the plugin is disabled so all the buttons
|
61
|
-
// and lines visually disappear. All the interactions are disabled in individual plugin methods.
|
62
|
-
this.on('change:isEnabled', (evt, data, isEnabled) => {
|
63
|
-
editingView.change(writer => {
|
64
|
-
for (const root of editingView.document.roots) {
|
65
|
-
if (isEnabled) {
|
66
|
-
writer.removeClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
|
67
|
-
}
|
68
|
-
else {
|
69
|
-
writer.addClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
|
70
|
-
}
|
71
|
-
}
|
72
|
-
});
|
73
|
-
if (!isEnabled) {
|
74
|
-
editor.model.change(writer => {
|
75
|
-
writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
|
76
|
-
});
|
77
|
-
}
|
78
|
-
});
|
79
|
-
this._enableTypeAroundUIInjection();
|
80
|
-
this._enableInsertingParagraphsOnButtonClick();
|
81
|
-
this._enableInsertingParagraphsOnEnterKeypress();
|
82
|
-
this._enableInsertingParagraphsOnTypingKeystroke();
|
83
|
-
this._enableTypeAroundFakeCaretActivationUsingKeyboardArrows();
|
84
|
-
this._enableDeleteIntegration();
|
85
|
-
this._enableInsertContentIntegration();
|
86
|
-
this._enableInsertObjectIntegration();
|
87
|
-
this._enableDeleteContentIntegration();
|
88
|
-
}
|
89
|
-
/**
|
90
|
-
* @inheritDoc
|
91
|
-
*/
|
92
|
-
destroy() {
|
93
|
-
super.destroy();
|
94
|
-
this._currentFakeCaretModelElement = null;
|
95
|
-
}
|
96
|
-
/**
|
97
|
-
* Inserts a new paragraph next to a widget element with the selection anchored in it.
|
98
|
-
*
|
99
|
-
* **Note**: This method is heavily user-oriented and will both focus the editing view and scroll
|
100
|
-
* the viewport to the selection in the inserted paragraph.
|
101
|
-
*
|
102
|
-
* @param widgetModelElement The model widget element next to which a paragraph is inserted.
|
103
|
-
* @param position The position where the paragraph is inserted. Either `'before'` or `'after'` the widget.
|
104
|
-
*/
|
105
|
-
_insertParagraph(widgetModelElement, position) {
|
106
|
-
const editor = this.editor;
|
107
|
-
const editingView = editor.editing.view;
|
108
|
-
const attributesToCopy = editor.model.schema.getAttributesWithProperty(widgetModelElement, 'copyOnReplace', true);
|
109
|
-
editor.execute('insertParagraph', {
|
110
|
-
position: editor.model.createPositionAt(widgetModelElement, position),
|
111
|
-
attributes: attributesToCopy
|
112
|
-
});
|
113
|
-
editingView.focus();
|
114
|
-
editingView.scrollToTheSelection();
|
115
|
-
}
|
116
|
-
/**
|
117
|
-
* A wrapper for the {@link module:utils/emittermixin~Emitter#listenTo} method that executes the callbacks only
|
118
|
-
* when the plugin {@link #isEnabled is enabled}.
|
119
|
-
*
|
120
|
-
* @param emitter The object that fires the event.
|
121
|
-
* @param event The name of the event.
|
122
|
-
* @param callback The function to be called on event.
|
123
|
-
* @param options Additional options.
|
124
|
-
* @param options.priority The priority of this event callback. The higher the priority value the sooner
|
125
|
-
* the callback will be fired. Events having the same priority are called in the order they were added.
|
126
|
-
*/
|
127
|
-
_listenToIfEnabled(emitter, event, callback, options) {
|
128
|
-
this.listenTo(emitter, event, (...args) => {
|
129
|
-
// Do not respond if the plugin is disabled.
|
130
|
-
if (this.isEnabled) {
|
131
|
-
callback(...args);
|
132
|
-
}
|
133
|
-
}, options);
|
134
|
-
}
|
135
|
-
/**
|
136
|
-
* Similar to {@link #_insertParagraph}, this method inserts a paragraph except that it
|
137
|
-
* does not expect a position. Instead, it performs the insertion next to a selected widget
|
138
|
-
* according to the `widget-type-around` model selection attribute value (fake caret position).
|
139
|
-
*
|
140
|
-
* Because this method requires the `widget-type-around` attribute to be set,
|
141
|
-
* the insertion can only happen when the widget's fake caret is active (e.g. activated
|
142
|
-
* using the keyboard).
|
143
|
-
*
|
144
|
-
* @returns Returns `true` when the paragraph was inserted (the attribute was present) and `false` otherwise.
|
145
|
-
*/
|
146
|
-
_insertParagraphAccordingToFakeCaretPosition() {
|
147
|
-
const editor = this.editor;
|
148
|
-
const model = editor.model;
|
149
|
-
const modelSelection = model.document.selection;
|
150
|
-
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection);
|
151
|
-
if (!typeAroundFakeCaretPosition) {
|
152
|
-
return false;
|
153
|
-
}
|
154
|
-
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
|
155
|
-
// @if CK_DEBUG_TYPING // console.info( '%c[WidgetTypeAround]%c Fake caret -> insert paragraph',
|
156
|
-
// @if CK_DEBUG_TYPING // 'font-weight: bold; color: green', ''
|
157
|
-
// @if CK_DEBUG_TYPING // );
|
158
|
-
// @if CK_DEBUG_TYPING // }
|
159
|
-
const selectedModelElement = modelSelection.getSelectedElement();
|
160
|
-
this._insertParagraph(selectedModelElement, typeAroundFakeCaretPosition);
|
161
|
-
return true;
|
162
|
-
}
|
163
|
-
/**
|
164
|
-
* Creates a listener in the editing conversion pipeline that injects the widget type around
|
165
|
-
* UI into every single widget instance created in the editor.
|
166
|
-
*
|
167
|
-
* The UI is delivered as a {@link module:engine/view/uielement~UIElement}
|
168
|
-
* wrapper which renders DOM buttons that users can use to insert paragraphs.
|
169
|
-
*/
|
170
|
-
_enableTypeAroundUIInjection() {
|
171
|
-
const editor = this.editor;
|
172
|
-
const schema = editor.model.schema;
|
173
|
-
const t = editor.locale.t;
|
174
|
-
const buttonTitles = {
|
175
|
-
before: t('Insert paragraph before block'),
|
176
|
-
after: t('Insert paragraph after block')
|
177
|
-
};
|
178
|
-
editor.editing.downcastDispatcher.on('insert', (evt, data, conversionApi) => {
|
179
|
-
const viewElement = conversionApi.mapper.toViewElement(data.item);
|
180
|
-
if (!viewElement) {
|
181
|
-
return;
|
182
|
-
}
|
183
|
-
// Filter out non-widgets and inline widgets.
|
184
|
-
if (isTypeAroundWidget(viewElement, data.item, schema)) {
|
185
|
-
injectUIIntoWidget(conversionApi.writer, buttonTitles, viewElement);
|
186
|
-
const widgetLabel = viewElement.getCustomProperty('widgetLabel');
|
187
|
-
widgetLabel.push(() => {
|
188
|
-
return this.isEnabled ? t('Press Enter to type after or press Shift + Enter to type before the widget') : '';
|
189
|
-
});
|
190
|
-
}
|
191
|
-
}, { priority: 'low' });
|
192
|
-
}
|
193
|
-
/**
|
194
|
-
* Brings support for the fake caret that appears when either:
|
195
|
-
*
|
196
|
-
* * the selection moves to a widget from a position next to it using arrow keys,
|
197
|
-
* * the arrow key is pressed when the widget is already selected.
|
198
|
-
*
|
199
|
-
* The fake caret lets the user know that they can start typing or just press
|
200
|
-
* <kbd>Enter</kbd> to insert a paragraph at the position next to a widget as suggested by the fake caret.
|
201
|
-
*
|
202
|
-
* The fake caret disappears when the user changes the selection or the editor
|
203
|
-
* gets blurred.
|
204
|
-
*
|
205
|
-
* The whole idea is as follows:
|
206
|
-
*
|
207
|
-
* 1. A user does one of the 2 scenarios described at the beginning.
|
208
|
-
* 2. The "keydown" listener is executed and the decision is made whether to show or hide the fake caret.
|
209
|
-
* 3. If it should show up, the `widget-type-around` model selection attribute is set indicating
|
210
|
-
* on which side of the widget it should appear.
|
211
|
-
* 4. The selection dispatcher reacts to the selection attribute and sets CSS classes responsible for the
|
212
|
-
* fake caret on the view widget.
|
213
|
-
* 5. If the fake caret should disappear, the selection attribute is removed and the dispatcher
|
214
|
-
* does the CSS class clean-up in the view.
|
215
|
-
* 6. Additionally, `change:range` and `FocusTracker#isFocused` listeners also remove the selection
|
216
|
-
* attribute (the former also removes widget CSS classes).
|
217
|
-
*/
|
218
|
-
_enableTypeAroundFakeCaretActivationUsingKeyboardArrows() {
|
219
|
-
const editor = this.editor;
|
220
|
-
const model = editor.model;
|
221
|
-
const modelSelection = model.document.selection;
|
222
|
-
const schema = model.schema;
|
223
|
-
const editingView = editor.editing.view;
|
224
|
-
// This is the main listener responsible for the fake caret.
|
225
|
-
// Note: The priority must precede the default Widget class keydown handler ("high").
|
226
|
-
this._listenToIfEnabled(editingView.document, 'arrowKey', (evt, domEventData) => {
|
227
|
-
this._handleArrowKeyPress(evt, domEventData);
|
228
|
-
}, { context: [isWidget, '$text'], priority: 'high' });
|
229
|
-
// This listener makes sure the widget type around selection attribute will be gone from the model
|
230
|
-
// selection as soon as the model range changes. This attribute only makes sense when a widget is selected
|
231
|
-
// (and the "fake horizontal caret" is visible) so whenever the range changes (e.g. selection moved somewhere else),
|
232
|
-
// let's get rid of the attribute so that the selection downcast dispatcher isn't even bothered.
|
233
|
-
this._listenToIfEnabled(modelSelection, 'change:range', (evt, data) => {
|
234
|
-
// Do not reset the selection attribute when the change was indirect.
|
235
|
-
if (!data.directChange) {
|
236
|
-
return;
|
237
|
-
}
|
238
|
-
// Get rid of the widget type around attribute of the selection on every change:range.
|
239
|
-
// If the range changes, it means for sure, the user is no longer in the active ("fake horizontal caret") mode.
|
240
|
-
editor.model.change(writer => {
|
241
|
-
writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
|
242
|
-
});
|
243
|
-
});
|
244
|
-
// Get rid of the widget type around attribute of the selection on every document change
|
245
|
-
// that makes widget not selected any more (i.e. widget was removed).
|
246
|
-
this._listenToIfEnabled(model.document, 'change:data', () => {
|
247
|
-
const selectedModelElement = modelSelection.getSelectedElement();
|
248
|
-
if (selectedModelElement) {
|
249
|
-
const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
|
250
|
-
if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
|
251
|
-
return;
|
252
|
-
}
|
253
|
-
}
|
254
|
-
editor.model.change(writer => {
|
255
|
-
writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
|
256
|
-
});
|
257
|
-
});
|
258
|
-
// React to changes of the model selection attribute made by the arrow keys listener.
|
259
|
-
// If the block widget is selected and the attribute changes, downcast the attribute to special
|
260
|
-
// CSS classes associated with the active ("fake horizontal caret") mode of the widget.
|
261
|
-
this._listenToIfEnabled(editor.editing.downcastDispatcher, 'selection', (evt, data, conversionApi) => {
|
262
|
-
const writer = conversionApi.writer;
|
263
|
-
if (this._currentFakeCaretModelElement) {
|
264
|
-
const selectedViewElement = conversionApi.mapper.toViewElement(this._currentFakeCaretModelElement);
|
265
|
-
if (selectedViewElement) {
|
266
|
-
// Get rid of CSS classes associated with the active ("fake horizontal caret") mode from the view widget.
|
267
|
-
writer.removeClass(POSSIBLE_INSERTION_POSITIONS.map(positionToWidgetCssClass), selectedViewElement);
|
268
|
-
this._currentFakeCaretModelElement = null;
|
269
|
-
}
|
270
|
-
}
|
271
|
-
const selectedModelElement = data.selection.getSelectedElement();
|
272
|
-
if (!selectedModelElement) {
|
273
|
-
return;
|
274
|
-
}
|
275
|
-
const selectedViewElement = conversionApi.mapper.toViewElement(selectedModelElement);
|
276
|
-
if (!isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
|
277
|
-
return;
|
278
|
-
}
|
279
|
-
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(data.selection);
|
280
|
-
if (!typeAroundFakeCaretPosition) {
|
281
|
-
return;
|
282
|
-
}
|
283
|
-
writer.addClass(positionToWidgetCssClass(typeAroundFakeCaretPosition), selectedViewElement);
|
284
|
-
// Remember the view widget that got the "fake-caret" CSS class. This class should be removed ASAP when the
|
285
|
-
// selection changes
|
286
|
-
this._currentFakeCaretModelElement = selectedModelElement;
|
287
|
-
});
|
288
|
-
this._listenToIfEnabled(editor.ui.focusTracker, 'change:isFocused', (evt, name, isFocused) => {
|
289
|
-
if (!isFocused) {
|
290
|
-
editor.model.change(writer => {
|
291
|
-
writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
|
292
|
-
});
|
293
|
-
}
|
294
|
-
});
|
295
|
-
function positionToWidgetCssClass(position) {
|
296
|
-
return `ck-widget_type-around_show-fake-caret_${position}`;
|
297
|
-
}
|
298
|
-
}
|
299
|
-
/**
|
300
|
-
* A listener executed on each "keydown" in the view document, a part of
|
301
|
-
* {@link #_enableTypeAroundFakeCaretActivationUsingKeyboardArrows}.
|
302
|
-
*
|
303
|
-
* It decides whether the arrow keypress should activate the fake caret or not (also whether it should
|
304
|
-
* be deactivated).
|
305
|
-
*
|
306
|
-
* The fake caret activation is done by setting the `widget-type-around` model selection attribute
|
307
|
-
* in this listener, and stopping and preventing the event that would normally be handled by the widget
|
308
|
-
* plugin that is responsible for the regular keyboard navigation near/across all widgets (that
|
309
|
-
* includes inline widgets, which are ignored by the widget type around plugin).
|
310
|
-
*/
|
311
|
-
_handleArrowKeyPress(evt, domEventData) {
|
312
|
-
const editor = this.editor;
|
313
|
-
const model = editor.model;
|
314
|
-
const modelSelection = model.document.selection;
|
315
|
-
const schema = model.schema;
|
316
|
-
const editingView = editor.editing.view;
|
317
|
-
const keyCode = domEventData.keyCode;
|
318
|
-
const isForward = isForwardArrowKeyCode(keyCode, editor.locale.contentLanguageDirection);
|
319
|
-
const selectedViewElement = editingView.document.selection.getSelectedElement();
|
320
|
-
const selectedModelElement = editor.editing.mapper.toModelElement(selectedViewElement);
|
321
|
-
let shouldStopAndPreventDefault;
|
322
|
-
// Handle keyboard navigation when a type-around-compatible widget is currently selected.
|
323
|
-
if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
|
324
|
-
shouldStopAndPreventDefault = this._handleArrowKeyPressOnSelectedWidget(isForward);
|
325
|
-
}
|
326
|
-
// Handle keyboard arrow navigation when the selection is next to a type-around-compatible widget
|
327
|
-
// and the widget is about to be selected.
|
328
|
-
else if (modelSelection.isCollapsed) {
|
329
|
-
shouldStopAndPreventDefault = this._handleArrowKeyPressWhenSelectionNextToAWidget(isForward);
|
330
|
-
}
|
331
|
-
// Handle collapsing a non-collapsed selection that is wider than on a single widget.
|
332
|
-
else if (!domEventData.shiftKey) {
|
333
|
-
shouldStopAndPreventDefault = this._handleArrowKeyPressWhenNonCollapsedSelection(isForward);
|
334
|
-
}
|
335
|
-
if (shouldStopAndPreventDefault) {
|
336
|
-
domEventData.preventDefault();
|
337
|
-
evt.stop();
|
338
|
-
}
|
339
|
-
}
|
340
|
-
/**
|
341
|
-
* Handles the keyboard navigation on "keydown" when a widget is currently selected and activates or deactivates
|
342
|
-
* the fake caret for that widget, depending on the current value of the `widget-type-around` model
|
343
|
-
* selection attribute and the direction of the pressed arrow key.
|
344
|
-
*
|
345
|
-
* @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
|
346
|
-
* as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
|
347
|
-
* @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
|
348
|
-
* process the event any further. Returns `false` otherwise.
|
349
|
-
*/
|
350
|
-
_handleArrowKeyPressOnSelectedWidget(isForward) {
|
351
|
-
const editor = this.editor;
|
352
|
-
const model = editor.model;
|
353
|
-
const modelSelection = model.document.selection;
|
354
|
-
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection);
|
355
|
-
return model.change(writer => {
|
356
|
-
// If the fake caret is displayed...
|
357
|
-
if (typeAroundFakeCaretPosition) {
|
358
|
-
const isLeavingWidget = typeAroundFakeCaretPosition === (isForward ? 'after' : 'before');
|
359
|
-
// If the keyboard arrow works against the value of the selection attribute...
|
360
|
-
// then remove the selection attribute but prevent default DOM actions
|
361
|
-
// and do not let the Widget plugin listener move the selection. This brings
|
362
|
-
// the widget back to the state, for instance, like if was selected using the mouse.
|
363
|
-
//
|
364
|
-
// **Note**: If leaving the widget when the fake caret is active, then the default
|
365
|
-
// Widget handler will change the selection and, in turn, this will automatically discard
|
366
|
-
// the selection attribute.
|
367
|
-
if (!isLeavingWidget) {
|
368
|
-
writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
|
369
|
-
return true;
|
370
|
-
}
|
371
|
-
}
|
372
|
-
// If the fake caret wasn't displayed, let's set it now according to the direction of the arrow
|
373
|
-
// key press. This also means we cannot let the Widget plugin listener move the selection.
|
374
|
-
else {
|
375
|
-
writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before');
|
376
|
-
return true;
|
377
|
-
}
|
378
|
-
return false;
|
379
|
-
});
|
380
|
-
}
|
381
|
-
/**
|
382
|
-
* Handles the keyboard navigation on "keydown" when **no** widget is selected but the selection is **directly** next
|
383
|
-
* to one and upon the fake caret should become active for this widget upon arrow keypress
|
384
|
-
* (AKA entering/selecting the widget).
|
385
|
-
*
|
386
|
-
* **Note**: This code mirrors the implementation from the widget plugin but also adds the selection attribute.
|
387
|
-
* Unfortunately, there is no safe way to let the widget plugin do the selection part first and then just set the
|
388
|
-
* selection attribute here in the widget type around plugin. This is why this code must duplicate some from the widget plugin.
|
389
|
-
*
|
390
|
-
* @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
|
391
|
-
* as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
|
392
|
-
* @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
|
393
|
-
* process the event any further. Returns `false` otherwise.
|
394
|
-
*/
|
395
|
-
_handleArrowKeyPressWhenSelectionNextToAWidget(isForward) {
|
396
|
-
const editor = this.editor;
|
397
|
-
const model = editor.model;
|
398
|
-
const schema = model.schema;
|
399
|
-
const widgetPlugin = editor.plugins.get('Widget');
|
400
|
-
// This is the widget the selection is about to be set on.
|
401
|
-
const modelElementNextToSelection = widgetPlugin._getObjectElementNextToSelection(isForward);
|
402
|
-
const viewElementNextToSelection = editor.editing.mapper.toViewElement(modelElementNextToSelection);
|
403
|
-
if (isTypeAroundWidget(viewElementNextToSelection, modelElementNextToSelection, schema)) {
|
404
|
-
model.change(writer => {
|
405
|
-
widgetPlugin._setSelectionOverElement(modelElementNextToSelection);
|
406
|
-
writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'before' : 'after');
|
407
|
-
});
|
408
|
-
// The change() block above does the same job as the Widget plugin. The event can
|
409
|
-
// be safely canceled.
|
410
|
-
return true;
|
411
|
-
}
|
412
|
-
return false;
|
413
|
-
}
|
414
|
-
/**
|
415
|
-
* Handles the keyboard navigation on "keydown" when a widget is currently selected (together with some other content)
|
416
|
-
* and the widget is the first or last element in the selection. It activates or deactivates the fake caret for that widget.
|
417
|
-
*
|
418
|
-
* @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
|
419
|
-
* as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
|
420
|
-
* @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
|
421
|
-
* process the event any further. Returns `false` otherwise.
|
422
|
-
*/
|
423
|
-
_handleArrowKeyPressWhenNonCollapsedSelection(isForward) {
|
424
|
-
const editor = this.editor;
|
425
|
-
const model = editor.model;
|
426
|
-
const schema = model.schema;
|
427
|
-
const mapper = editor.editing.mapper;
|
428
|
-
const modelSelection = model.document.selection;
|
429
|
-
const selectedModelNode = isForward ?
|
430
|
-
modelSelection.getLastPosition().nodeBefore :
|
431
|
-
modelSelection.getFirstPosition().nodeAfter;
|
432
|
-
const selectedViewNode = mapper.toViewElement(selectedModelNode);
|
433
|
-
// There is a widget at the collapse position so collapse the selection to the fake caret on it.
|
434
|
-
if (isTypeAroundWidget(selectedViewNode, selectedModelNode, schema)) {
|
435
|
-
model.change(writer => {
|
436
|
-
writer.setSelection(selectedModelNode, 'on');
|
437
|
-
writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before');
|
438
|
-
});
|
439
|
-
return true;
|
440
|
-
}
|
441
|
-
return false;
|
442
|
-
}
|
443
|
-
/**
|
444
|
-
* Registers a `mousedown` listener for the view document which intercepts events
|
445
|
-
* coming from the widget type around UI, which happens when a user clicks one of the buttons
|
446
|
-
* that insert a paragraph next to a widget.
|
447
|
-
*/
|
448
|
-
_enableInsertingParagraphsOnButtonClick() {
|
449
|
-
const editor = this.editor;
|
450
|
-
const editingView = editor.editing.view;
|
451
|
-
this._listenToIfEnabled(editingView.document, 'mousedown', (evt, domEventData) => {
|
452
|
-
const button = getClosestTypeAroundDomButton(domEventData.domTarget);
|
453
|
-
if (!button) {
|
454
|
-
return;
|
455
|
-
}
|
456
|
-
const buttonPosition = getTypeAroundButtonPosition(button);
|
457
|
-
const widgetViewElement = getClosestWidgetViewElement(button, editingView.domConverter);
|
458
|
-
const widgetModelElement = editor.editing.mapper.toModelElement(widgetViewElement);
|
459
|
-
this._insertParagraph(widgetModelElement, buttonPosition);
|
460
|
-
domEventData.preventDefault();
|
461
|
-
evt.stop();
|
462
|
-
});
|
463
|
-
}
|
464
|
-
/**
|
465
|
-
* Creates the <kbd>Enter</kbd> key listener on the view document that allows the user to insert a paragraph
|
466
|
-
* near the widget when either:
|
467
|
-
*
|
468
|
-
* * The fake caret was first activated using the arrow keys,
|
469
|
-
* * The entire widget is selected in the model.
|
470
|
-
*
|
471
|
-
* In the first case, the new paragraph is inserted according to the `widget-type-around` selection
|
472
|
-
* attribute (see {@link #_handleArrowKeyPress}).
|
473
|
-
*
|
474
|
-
* In the second case, the new paragraph is inserted based on whether a soft (<kbd>Shift</kbd>+<kbd>Enter</kbd>) keystroke
|
475
|
-
* was pressed or not.
|
476
|
-
*/
|
477
|
-
_enableInsertingParagraphsOnEnterKeypress() {
|
478
|
-
const editor = this.editor;
|
479
|
-
const selection = editor.model.document.selection;
|
480
|
-
const editingView = editor.editing.view;
|
481
|
-
this._listenToIfEnabled(editingView.document, 'enter', (evt, domEventData) => {
|
482
|
-
// This event could be triggered from inside the widget but we are interested
|
483
|
-
// only when the widget is selected itself.
|
484
|
-
if (evt.eventPhase != 'atTarget') {
|
485
|
-
return;
|
486
|
-
}
|
487
|
-
const selectedModelElement = selection.getSelectedElement();
|
488
|
-
const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
|
489
|
-
const schema = editor.model.schema;
|
490
|
-
let wasHandled;
|
491
|
-
// First check if the widget is selected and there's a type around selection attribute associated
|
492
|
-
// with the fake caret that would tell where to insert a new paragraph.
|
493
|
-
if (this._insertParagraphAccordingToFakeCaretPosition()) {
|
494
|
-
wasHandled = true;
|
495
|
-
}
|
496
|
-
// Then, if there is no selection attribute associated with the fake caret, check if the widget
|
497
|
-
// simply is selected and create a new paragraph according to the keystroke (Shift+)Enter.
|
498
|
-
else if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
|
499
|
-
this._insertParagraph(selectedModelElement, domEventData.isSoft ? 'before' : 'after');
|
500
|
-
wasHandled = true;
|
501
|
-
}
|
502
|
-
if (wasHandled) {
|
503
|
-
domEventData.preventDefault();
|
504
|
-
evt.stop();
|
505
|
-
}
|
506
|
-
}, { context: isWidget });
|
507
|
-
}
|
508
|
-
/**
|
509
|
-
* Similar to the {@link #_enableInsertingParagraphsOnEnterKeypress}, it allows the user
|
510
|
-
* to insert a paragraph next to a widget when the fake caret was activated using arrow
|
511
|
-
* keys but it responds to typing instead of <kbd>Enter</kbd>.
|
512
|
-
*
|
513
|
-
* Listener enabled by this method will insert a new paragraph according to the `widget-type-around`
|
514
|
-
* model selection attribute as the user simply starts typing, which creates the impression that the fake caret
|
515
|
-
* behaves like a real one rendered by the browser (AKA your text appears where the caret was).
|
516
|
-
*
|
517
|
-
* **Note**: At the moment this listener creates 2 undo steps: one for the `insertParagraph` command
|
518
|
-
* and another one for actual typing. It is not a disaster but this may need to be fixed
|
519
|
-
* sooner or later.
|
520
|
-
*/
|
521
|
-
_enableInsertingParagraphsOnTypingKeystroke() {
|
522
|
-
const editor = this.editor;
|
523
|
-
const viewDocument = editor.editing.view.document;
|
524
|
-
// Note: The priority must precede the default Input plugin insertText handler.
|
525
|
-
this._listenToIfEnabled(viewDocument, 'insertText', (evt, data) => {
|
526
|
-
if (this._insertParagraphAccordingToFakeCaretPosition()) {
|
527
|
-
// The view selection in the event data contains the widget. If the new paragraph
|
528
|
-
// was inserted, modify the view selection passed along with the insertText event
|
529
|
-
// so the default event handler in the Input plugin starts typing inside the paragraph.
|
530
|
-
// Otherwise, the typing would be over the widget.
|
531
|
-
data.selection = viewDocument.selection;
|
532
|
-
}
|
533
|
-
}, { priority: 'high' });
|
534
|
-
if (env.isAndroid) {
|
535
|
-
// On Android with English keyboard, the composition starts just by putting caret
|
536
|
-
// at the word end or by selecting a table column. This is not a real composition started.
|
537
|
-
// Trigger delete content on first composition key pressed.
|
538
|
-
this._listenToIfEnabled(viewDocument, 'keydown', (evt, data) => {
|
539
|
-
if (data.keyCode == 229) {
|
540
|
-
this._insertParagraphAccordingToFakeCaretPosition();
|
541
|
-
}
|
542
|
-
});
|
543
|
-
}
|
544
|
-
else {
|
545
|
-
// Note: The priority must precede the default Input plugin compositionstart handler (to call it before delete content).
|
546
|
-
this._listenToIfEnabled(viewDocument, 'compositionstart', () => {
|
547
|
-
this._insertParagraphAccordingToFakeCaretPosition();
|
548
|
-
}, { priority: 'high' });
|
549
|
-
}
|
550
|
-
}
|
551
|
-
/**
|
552
|
-
* It creates a "delete" event listener on the view document to handle cases when the <kbd>Delete</kbd> or <kbd>Backspace</kbd>
|
553
|
-
* is pressed and the fake caret is currently active.
|
554
|
-
*
|
555
|
-
* The fake caret should create an illusion of a real browser caret so that when it appears before or after
|
556
|
-
* a widget, pressing <kbd>Delete</kbd> or <kbd>Backspace</kbd> should remove a widget or delete the content
|
557
|
-
* before or after a widget (depending on the content surrounding the widget).
|
558
|
-
*/
|
559
|
-
_enableDeleteIntegration() {
|
560
|
-
const editor = this.editor;
|
561
|
-
const editingView = editor.editing.view;
|
562
|
-
const model = editor.model;
|
563
|
-
const schema = model.schema;
|
564
|
-
this._listenToIfEnabled(editingView.document, 'delete', (evt, domEventData) => {
|
565
|
-
// This event could be triggered from inside the widget but we are interested
|
566
|
-
// only when the widget is selected itself.
|
567
|
-
if (evt.eventPhase != 'atTarget') {
|
568
|
-
return;
|
569
|
-
}
|
570
|
-
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(model.document.selection);
|
571
|
-
// This listener handles only these cases when the fake caret is active.
|
572
|
-
if (!typeAroundFakeCaretPosition) {
|
573
|
-
return;
|
574
|
-
}
|
575
|
-
const direction = domEventData.direction;
|
576
|
-
const selectedModelWidget = model.document.selection.getSelectedElement();
|
577
|
-
const isFakeCaretBefore = typeAroundFakeCaretPosition === 'before';
|
578
|
-
const isDeleteForward = direction == 'forward';
|
579
|
-
const shouldDeleteEntireWidget = isFakeCaretBefore === isDeleteForward;
|
580
|
-
if (shouldDeleteEntireWidget) {
|
581
|
-
editor.execute('delete', {
|
582
|
-
selection: model.createSelection(selectedModelWidget, 'on')
|
583
|
-
});
|
584
|
-
}
|
585
|
-
else {
|
586
|
-
const range = schema.getNearestSelectionRange(model.createPositionAt(selectedModelWidget, typeAroundFakeCaretPosition), direction);
|
587
|
-
// If there is somewhere to move selection to, then there will be something to delete.
|
588
|
-
if (range) {
|
589
|
-
// If the range is NOT collapsed, then we know that the range contains an object (see getNearestSelectionRange() docs).
|
590
|
-
if (!range.isCollapsed) {
|
591
|
-
model.change(writer => {
|
592
|
-
writer.setSelection(range);
|
593
|
-
editor.execute(isDeleteForward ? 'deleteForward' : 'delete');
|
594
|
-
});
|
595
|
-
}
|
596
|
-
else {
|
597
|
-
const probe = model.createSelection(range.start);
|
598
|
-
model.modifySelection(probe, { direction });
|
599
|
-
// If the range is collapsed, let's see if a non-collapsed range exists that can could be deleted.
|
600
|
-
// If such range exists, use the editor command because it it safe for collaboration (it merges where it can).
|
601
|
-
if (!probe.focus.isEqual(range.start)) {
|
602
|
-
model.change(writer => {
|
603
|
-
writer.setSelection(range);
|
604
|
-
editor.execute(isDeleteForward ? 'deleteForward' : 'delete');
|
605
|
-
});
|
606
|
-
}
|
607
|
-
// If there is no non-collapsed range to be deleted then we are sure that there is an empty element
|
608
|
-
// next to a widget that should be removed. "delete" and "deleteForward" commands cannot get rid of it
|
609
|
-
// so calling Model#deleteContent here manually.
|
610
|
-
else {
|
611
|
-
const deepestEmptyRangeAncestor = getDeepestEmptyElementAncestor(schema, range.start.parent);
|
612
|
-
model.deleteContent(model.createSelection(deepestEmptyRangeAncestor, 'on'), {
|
613
|
-
doNotAutoparagraph: true
|
614
|
-
});
|
615
|
-
}
|
616
|
-
}
|
617
|
-
}
|
618
|
-
}
|
619
|
-
// If some content was deleted, don't let the handler from the Widget plugin kick in.
|
620
|
-
// If nothing was deleted, then the default handler will have nothing to do anyway.
|
621
|
-
domEventData.preventDefault();
|
622
|
-
evt.stop();
|
623
|
-
}, { context: isWidget });
|
624
|
-
}
|
625
|
-
/**
|
626
|
-
* Attaches the {@link module:engine/model/model~Model#event:insertContent} event listener that, for instance, allows the user to paste
|
627
|
-
* content near a widget when the fake caret is first activated using the arrow keys.
|
628
|
-
*
|
629
|
-
* The content is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
|
630
|
-
*/
|
631
|
-
_enableInsertContentIntegration() {
|
632
|
-
const editor = this.editor;
|
633
|
-
const model = this.editor.model;
|
634
|
-
const documentSelection = model.document.selection;
|
635
|
-
this._listenToIfEnabled(editor.model, 'insertContent', (evt, [content, selectable]) => {
|
636
|
-
if (selectable && !selectable.is('documentSelection')) {
|
637
|
-
return;
|
638
|
-
}
|
639
|
-
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
|
640
|
-
if (!typeAroundFakeCaretPosition) {
|
641
|
-
return;
|
642
|
-
}
|
643
|
-
evt.stop();
|
644
|
-
return model.change(writer => {
|
645
|
-
const selectedElement = documentSelection.getSelectedElement();
|
646
|
-
const position = model.createPositionAt(selectedElement, typeAroundFakeCaretPosition);
|
647
|
-
const selection = writer.createSelection(position);
|
648
|
-
const result = model.insertContent(content, selection);
|
649
|
-
writer.setSelection(selection);
|
650
|
-
return result;
|
651
|
-
});
|
652
|
-
}, { priority: 'high' });
|
653
|
-
}
|
654
|
-
/**
|
655
|
-
* Attaches the {@link module:engine/model/model~Model#event:insertObject} event listener that modifies the
|
656
|
-
* `options.findOptimalPosition`parameter to position of fake caret in relation to selected element
|
657
|
-
* to reflect user's intent of desired insertion position.
|
658
|
-
*
|
659
|
-
* The object is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
|
660
|
-
*/
|
661
|
-
_enableInsertObjectIntegration() {
|
662
|
-
const editor = this.editor;
|
663
|
-
const model = this.editor.model;
|
664
|
-
const documentSelection = model.document.selection;
|
665
|
-
this._listenToIfEnabled(editor.model, 'insertObject', (evt, args) => {
|
666
|
-
const [, selectable, options = {}] = args;
|
667
|
-
if (selectable && !selectable.is('documentSelection')) {
|
668
|
-
return;
|
669
|
-
}
|
670
|
-
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
|
671
|
-
if (!typeAroundFakeCaretPosition) {
|
672
|
-
return;
|
673
|
-
}
|
674
|
-
options.findOptimalPosition = typeAroundFakeCaretPosition;
|
675
|
-
args[3] = options;
|
676
|
-
}, { priority: 'high' });
|
677
|
-
}
|
678
|
-
/**
|
679
|
-
* Attaches the {@link module:engine/model/model~Model#event:deleteContent} event listener to block the event when the fake
|
680
|
-
* caret is active.
|
681
|
-
*
|
682
|
-
* This is required for cases that trigger {@link module:engine/model/model~Model#deleteContent `model.deleteContent()`}
|
683
|
-
* before calling {@link module:engine/model/model~Model#insertContent `model.insertContent()`} like, for instance,
|
684
|
-
* plain text pasting.
|
685
|
-
*/
|
686
|
-
_enableDeleteContentIntegration() {
|
687
|
-
const editor = this.editor;
|
688
|
-
const model = this.editor.model;
|
689
|
-
const documentSelection = model.document.selection;
|
690
|
-
this._listenToIfEnabled(editor.model, 'deleteContent', (evt, [selection]) => {
|
691
|
-
if (selection && !selection.is('documentSelection')) {
|
692
|
-
return;
|
693
|
-
}
|
694
|
-
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
|
695
|
-
// Disable removing the selection content while pasting plain text.
|
696
|
-
if (typeAroundFakeCaretPosition) {
|
697
|
-
evt.stop();
|
698
|
-
}
|
699
|
-
}, { priority: 'high' });
|
700
|
-
}
|
701
|
-
}
|
702
|
-
/**
|
703
|
-
* Injects the type around UI into a view widget instance.
|
704
|
-
*/
|
705
|
-
function injectUIIntoWidget(viewWriter, buttonTitles, widgetViewElement) {
|
706
|
-
const typeAroundWrapper = viewWriter.createUIElement('div', {
|
707
|
-
class: 'ck ck-reset_all ck-widget__type-around'
|
708
|
-
}, function (domDocument) {
|
709
|
-
const wrapperDomElement = this.toDomElement(domDocument);
|
710
|
-
injectButtons(wrapperDomElement, buttonTitles);
|
711
|
-
injectFakeCaret(wrapperDomElement);
|
712
|
-
return wrapperDomElement;
|
713
|
-
});
|
714
|
-
// Inject the type around wrapper into the widget's wrapper.
|
715
|
-
viewWriter.insert(viewWriter.createPositionAt(widgetViewElement, 'end'), typeAroundWrapper);
|
716
|
-
}
|
717
|
-
/**
|
718
|
-
* FYI: Not using the IconView class because each instance would need to be destroyed to avoid memory leaks
|
719
|
-
* and it's pretty hard to figure out when a view (widget) is gone for good so it's cheaper to use raw
|
720
|
-
* <svg> here.
|
721
|
-
*/
|
722
|
-
function injectButtons(wrapperDomElement, buttonTitles) {
|
723
|
-
for (const position of POSSIBLE_INSERTION_POSITIONS) {
|
724
|
-
const buttonTemplate = new Template({
|
725
|
-
tag: 'div',
|
726
|
-
attributes: {
|
727
|
-
class: [
|
728
|
-
'ck',
|
729
|
-
'ck-widget__type-around__button',
|
730
|
-
`ck-widget__type-around__button_${position}`
|
731
|
-
],
|
732
|
-
title: buttonTitles[position],
|
733
|
-
'aria-hidden': 'true'
|
734
|
-
},
|
735
|
-
children: [
|
736
|
-
wrapperDomElement.ownerDocument.importNode(RETURN_ARROW_ICON_ELEMENT, true)
|
737
|
-
]
|
738
|
-
});
|
739
|
-
wrapperDomElement.appendChild(buttonTemplate.render());
|
740
|
-
}
|
741
|
-
}
|
742
|
-
function injectFakeCaret(wrapperDomElement) {
|
743
|
-
const caretTemplate = new Template({
|
744
|
-
tag: 'div',
|
745
|
-
attributes: {
|
746
|
-
class: [
|
747
|
-
'ck',
|
748
|
-
'ck-widget__type-around__fake-caret'
|
749
|
-
]
|
750
|
-
}
|
751
|
-
});
|
752
|
-
wrapperDomElement.appendChild(caretTemplate.render());
|
753
|
-
}
|
754
|
-
/**
|
755
|
-
* Returns the ancestor of an element closest to the root which is empty. For instance,
|
756
|
-
* for `<baz>`:
|
757
|
-
*
|
758
|
-
* ```
|
759
|
-
* <foo>abc<bar><baz></baz></bar></foo>
|
760
|
-
* ```
|
761
|
-
*
|
762
|
-
* it returns `<bar>`.
|
763
|
-
*/
|
764
|
-
function getDeepestEmptyElementAncestor(schema, element) {
|
765
|
-
let deepestEmptyAncestor = element;
|
766
|
-
for (const ancestor of element.getAncestors({ parentFirst: true })) {
|
767
|
-
if (ancestor.childCount > 1 || schema.isLimit(ancestor)) {
|
768
|
-
break;
|
769
|
-
}
|
770
|
-
deepestEmptyAncestor = ancestor;
|
771
|
-
}
|
772
|
-
return deepestEmptyAncestor;
|
773
|
-
}
|
1
|
+
/**
|
2
|
+
* @license Copyright (c) 2003-2023, 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
|
+
/* global DOMParser */
|
6
|
+
/**
|
7
|
+
* @module widget/widgettypearound/widgettypearound
|
8
|
+
*/
|
9
|
+
import { Plugin } from '@ckeditor/ckeditor5-core';
|
10
|
+
import { Template } from '@ckeditor/ckeditor5-ui';
|
11
|
+
import { Enter } from '@ckeditor/ckeditor5-enter';
|
12
|
+
import { Delete } from '@ckeditor/ckeditor5-typing';
|
13
|
+
import { env, isForwardArrowKeyCode } from '@ckeditor/ckeditor5-utils';
|
14
|
+
import { isTypeAroundWidget, getClosestTypeAroundDomButton, getTypeAroundButtonPosition, getClosestWidgetViewElement, getTypeAroundFakeCaretPosition, TYPE_AROUND_SELECTION_ATTRIBUTE } from './utils';
|
15
|
+
import { isWidget } from '../utils';
|
16
|
+
import returnIcon from '../../theme/icons/return-arrow.svg';
|
17
|
+
import '../../theme/widgettypearound.css';
|
18
|
+
const POSSIBLE_INSERTION_POSITIONS = ['before', 'after'];
|
19
|
+
// Do the SVG parsing once and then clone the result <svg> DOM element for each new button.
|
20
|
+
const RETURN_ARROW_ICON_ELEMENT = new DOMParser().parseFromString(returnIcon, 'image/svg+xml').firstChild;
|
21
|
+
const PLUGIN_DISABLED_EDITING_ROOT_CLASS = 'ck-widget__type-around_disabled';
|
22
|
+
/**
|
23
|
+
* A plugin that allows users to type around widgets where normally it is impossible to place the caret due
|
24
|
+
* to limitations of web browsers. These "tight spots" occur, for instance, before (or after) a widget being
|
25
|
+
* the first (or last) child of its parent or between two block widgets.
|
26
|
+
*
|
27
|
+
* This plugin extends the {@link module:widget/widget~Widget `Widget`} plugin and injects the user interface
|
28
|
+
* with two buttons into each widget instance in the editor. Each of the buttons can be clicked by the
|
29
|
+
* user if the widget is next to the "tight spot". Once clicked, a paragraph is created with the selection anchored
|
30
|
+
* in it so that users can type (or insert content, paste, etc.) straight away.
|
31
|
+
*/
|
32
|
+
export default class WidgetTypeAround extends Plugin {
|
33
|
+
constructor() {
|
34
|
+
super(...arguments);
|
35
|
+
/**
|
36
|
+
* A reference to the model widget element that has the fake caret active
|
37
|
+
* on either side of it. It is later used to remove CSS classes associated with the fake caret
|
38
|
+
* when the widget no longer needs it.
|
39
|
+
*/
|
40
|
+
this._currentFakeCaretModelElement = null;
|
41
|
+
}
|
42
|
+
/**
|
43
|
+
* @inheritDoc
|
44
|
+
*/
|
45
|
+
static get pluginName() {
|
46
|
+
return 'WidgetTypeAround';
|
47
|
+
}
|
48
|
+
/**
|
49
|
+
* @inheritDoc
|
50
|
+
*/
|
51
|
+
static get requires() {
|
52
|
+
return [Enter, Delete];
|
53
|
+
}
|
54
|
+
/**
|
55
|
+
* @inheritDoc
|
56
|
+
*/
|
57
|
+
init() {
|
58
|
+
const editor = this.editor;
|
59
|
+
const editingView = editor.editing.view;
|
60
|
+
// Set a CSS class on the view editing root when the plugin is disabled so all the buttons
|
61
|
+
// and lines visually disappear. All the interactions are disabled in individual plugin methods.
|
62
|
+
this.on('change:isEnabled', (evt, data, isEnabled) => {
|
63
|
+
editingView.change(writer => {
|
64
|
+
for (const root of editingView.document.roots) {
|
65
|
+
if (isEnabled) {
|
66
|
+
writer.removeClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
|
67
|
+
}
|
68
|
+
else {
|
69
|
+
writer.addClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
|
70
|
+
}
|
71
|
+
}
|
72
|
+
});
|
73
|
+
if (!isEnabled) {
|
74
|
+
editor.model.change(writer => {
|
75
|
+
writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
|
76
|
+
});
|
77
|
+
}
|
78
|
+
});
|
79
|
+
this._enableTypeAroundUIInjection();
|
80
|
+
this._enableInsertingParagraphsOnButtonClick();
|
81
|
+
this._enableInsertingParagraphsOnEnterKeypress();
|
82
|
+
this._enableInsertingParagraphsOnTypingKeystroke();
|
83
|
+
this._enableTypeAroundFakeCaretActivationUsingKeyboardArrows();
|
84
|
+
this._enableDeleteIntegration();
|
85
|
+
this._enableInsertContentIntegration();
|
86
|
+
this._enableInsertObjectIntegration();
|
87
|
+
this._enableDeleteContentIntegration();
|
88
|
+
}
|
89
|
+
/**
|
90
|
+
* @inheritDoc
|
91
|
+
*/
|
92
|
+
destroy() {
|
93
|
+
super.destroy();
|
94
|
+
this._currentFakeCaretModelElement = null;
|
95
|
+
}
|
96
|
+
/**
|
97
|
+
* Inserts a new paragraph next to a widget element with the selection anchored in it.
|
98
|
+
*
|
99
|
+
* **Note**: This method is heavily user-oriented and will both focus the editing view and scroll
|
100
|
+
* the viewport to the selection in the inserted paragraph.
|
101
|
+
*
|
102
|
+
* @param widgetModelElement The model widget element next to which a paragraph is inserted.
|
103
|
+
* @param position The position where the paragraph is inserted. Either `'before'` or `'after'` the widget.
|
104
|
+
*/
|
105
|
+
_insertParagraph(widgetModelElement, position) {
|
106
|
+
const editor = this.editor;
|
107
|
+
const editingView = editor.editing.view;
|
108
|
+
const attributesToCopy = editor.model.schema.getAttributesWithProperty(widgetModelElement, 'copyOnReplace', true);
|
109
|
+
editor.execute('insertParagraph', {
|
110
|
+
position: editor.model.createPositionAt(widgetModelElement, position),
|
111
|
+
attributes: attributesToCopy
|
112
|
+
});
|
113
|
+
editingView.focus();
|
114
|
+
editingView.scrollToTheSelection();
|
115
|
+
}
|
116
|
+
/**
|
117
|
+
* A wrapper for the {@link module:utils/emittermixin~Emitter#listenTo} method that executes the callbacks only
|
118
|
+
* when the plugin {@link #isEnabled is enabled}.
|
119
|
+
*
|
120
|
+
* @param emitter The object that fires the event.
|
121
|
+
* @param event The name of the event.
|
122
|
+
* @param callback The function to be called on event.
|
123
|
+
* @param options Additional options.
|
124
|
+
* @param options.priority The priority of this event callback. The higher the priority value the sooner
|
125
|
+
* the callback will be fired. Events having the same priority are called in the order they were added.
|
126
|
+
*/
|
127
|
+
_listenToIfEnabled(emitter, event, callback, options) {
|
128
|
+
this.listenTo(emitter, event, (...args) => {
|
129
|
+
// Do not respond if the plugin is disabled.
|
130
|
+
if (this.isEnabled) {
|
131
|
+
callback(...args);
|
132
|
+
}
|
133
|
+
}, options);
|
134
|
+
}
|
135
|
+
/**
|
136
|
+
* Similar to {@link #_insertParagraph}, this method inserts a paragraph except that it
|
137
|
+
* does not expect a position. Instead, it performs the insertion next to a selected widget
|
138
|
+
* according to the `widget-type-around` model selection attribute value (fake caret position).
|
139
|
+
*
|
140
|
+
* Because this method requires the `widget-type-around` attribute to be set,
|
141
|
+
* the insertion can only happen when the widget's fake caret is active (e.g. activated
|
142
|
+
* using the keyboard).
|
143
|
+
*
|
144
|
+
* @returns Returns `true` when the paragraph was inserted (the attribute was present) and `false` otherwise.
|
145
|
+
*/
|
146
|
+
_insertParagraphAccordingToFakeCaretPosition() {
|
147
|
+
const editor = this.editor;
|
148
|
+
const model = editor.model;
|
149
|
+
const modelSelection = model.document.selection;
|
150
|
+
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection);
|
151
|
+
if (!typeAroundFakeCaretPosition) {
|
152
|
+
return false;
|
153
|
+
}
|
154
|
+
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
|
155
|
+
// @if CK_DEBUG_TYPING // console.info( '%c[WidgetTypeAround]%c Fake caret -> insert paragraph',
|
156
|
+
// @if CK_DEBUG_TYPING // 'font-weight: bold; color: green', ''
|
157
|
+
// @if CK_DEBUG_TYPING // );
|
158
|
+
// @if CK_DEBUG_TYPING // }
|
159
|
+
const selectedModelElement = modelSelection.getSelectedElement();
|
160
|
+
this._insertParagraph(selectedModelElement, typeAroundFakeCaretPosition);
|
161
|
+
return true;
|
162
|
+
}
|
163
|
+
/**
|
164
|
+
* Creates a listener in the editing conversion pipeline that injects the widget type around
|
165
|
+
* UI into every single widget instance created in the editor.
|
166
|
+
*
|
167
|
+
* The UI is delivered as a {@link module:engine/view/uielement~UIElement}
|
168
|
+
* wrapper which renders DOM buttons that users can use to insert paragraphs.
|
169
|
+
*/
|
170
|
+
_enableTypeAroundUIInjection() {
|
171
|
+
const editor = this.editor;
|
172
|
+
const schema = editor.model.schema;
|
173
|
+
const t = editor.locale.t;
|
174
|
+
const buttonTitles = {
|
175
|
+
before: t('Insert paragraph before block'),
|
176
|
+
after: t('Insert paragraph after block')
|
177
|
+
};
|
178
|
+
editor.editing.downcastDispatcher.on('insert', (evt, data, conversionApi) => {
|
179
|
+
const viewElement = conversionApi.mapper.toViewElement(data.item);
|
180
|
+
if (!viewElement) {
|
181
|
+
return;
|
182
|
+
}
|
183
|
+
// Filter out non-widgets and inline widgets.
|
184
|
+
if (isTypeAroundWidget(viewElement, data.item, schema)) {
|
185
|
+
injectUIIntoWidget(conversionApi.writer, buttonTitles, viewElement);
|
186
|
+
const widgetLabel = viewElement.getCustomProperty('widgetLabel');
|
187
|
+
widgetLabel.push(() => {
|
188
|
+
return this.isEnabled ? t('Press Enter to type after or press Shift + Enter to type before the widget') : '';
|
189
|
+
});
|
190
|
+
}
|
191
|
+
}, { priority: 'low' });
|
192
|
+
}
|
193
|
+
/**
|
194
|
+
* Brings support for the fake caret that appears when either:
|
195
|
+
*
|
196
|
+
* * the selection moves to a widget from a position next to it using arrow keys,
|
197
|
+
* * the arrow key is pressed when the widget is already selected.
|
198
|
+
*
|
199
|
+
* The fake caret lets the user know that they can start typing or just press
|
200
|
+
* <kbd>Enter</kbd> to insert a paragraph at the position next to a widget as suggested by the fake caret.
|
201
|
+
*
|
202
|
+
* The fake caret disappears when the user changes the selection or the editor
|
203
|
+
* gets blurred.
|
204
|
+
*
|
205
|
+
* The whole idea is as follows:
|
206
|
+
*
|
207
|
+
* 1. A user does one of the 2 scenarios described at the beginning.
|
208
|
+
* 2. The "keydown" listener is executed and the decision is made whether to show or hide the fake caret.
|
209
|
+
* 3. If it should show up, the `widget-type-around` model selection attribute is set indicating
|
210
|
+
* on which side of the widget it should appear.
|
211
|
+
* 4. The selection dispatcher reacts to the selection attribute and sets CSS classes responsible for the
|
212
|
+
* fake caret on the view widget.
|
213
|
+
* 5. If the fake caret should disappear, the selection attribute is removed and the dispatcher
|
214
|
+
* does the CSS class clean-up in the view.
|
215
|
+
* 6. Additionally, `change:range` and `FocusTracker#isFocused` listeners also remove the selection
|
216
|
+
* attribute (the former also removes widget CSS classes).
|
217
|
+
*/
|
218
|
+
_enableTypeAroundFakeCaretActivationUsingKeyboardArrows() {
|
219
|
+
const editor = this.editor;
|
220
|
+
const model = editor.model;
|
221
|
+
const modelSelection = model.document.selection;
|
222
|
+
const schema = model.schema;
|
223
|
+
const editingView = editor.editing.view;
|
224
|
+
// This is the main listener responsible for the fake caret.
|
225
|
+
// Note: The priority must precede the default Widget class keydown handler ("high").
|
226
|
+
this._listenToIfEnabled(editingView.document, 'arrowKey', (evt, domEventData) => {
|
227
|
+
this._handleArrowKeyPress(evt, domEventData);
|
228
|
+
}, { context: [isWidget, '$text'], priority: 'high' });
|
229
|
+
// This listener makes sure the widget type around selection attribute will be gone from the model
|
230
|
+
// selection as soon as the model range changes. This attribute only makes sense when a widget is selected
|
231
|
+
// (and the "fake horizontal caret" is visible) so whenever the range changes (e.g. selection moved somewhere else),
|
232
|
+
// let's get rid of the attribute so that the selection downcast dispatcher isn't even bothered.
|
233
|
+
this._listenToIfEnabled(modelSelection, 'change:range', (evt, data) => {
|
234
|
+
// Do not reset the selection attribute when the change was indirect.
|
235
|
+
if (!data.directChange) {
|
236
|
+
return;
|
237
|
+
}
|
238
|
+
// Get rid of the widget type around attribute of the selection on every change:range.
|
239
|
+
// If the range changes, it means for sure, the user is no longer in the active ("fake horizontal caret") mode.
|
240
|
+
editor.model.change(writer => {
|
241
|
+
writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
|
242
|
+
});
|
243
|
+
});
|
244
|
+
// Get rid of the widget type around attribute of the selection on every document change
|
245
|
+
// that makes widget not selected any more (i.e. widget was removed).
|
246
|
+
this._listenToIfEnabled(model.document, 'change:data', () => {
|
247
|
+
const selectedModelElement = modelSelection.getSelectedElement();
|
248
|
+
if (selectedModelElement) {
|
249
|
+
const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
|
250
|
+
if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
|
251
|
+
return;
|
252
|
+
}
|
253
|
+
}
|
254
|
+
editor.model.change(writer => {
|
255
|
+
writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
|
256
|
+
});
|
257
|
+
});
|
258
|
+
// React to changes of the model selection attribute made by the arrow keys listener.
|
259
|
+
// If the block widget is selected and the attribute changes, downcast the attribute to special
|
260
|
+
// CSS classes associated with the active ("fake horizontal caret") mode of the widget.
|
261
|
+
this._listenToIfEnabled(editor.editing.downcastDispatcher, 'selection', (evt, data, conversionApi) => {
|
262
|
+
const writer = conversionApi.writer;
|
263
|
+
if (this._currentFakeCaretModelElement) {
|
264
|
+
const selectedViewElement = conversionApi.mapper.toViewElement(this._currentFakeCaretModelElement);
|
265
|
+
if (selectedViewElement) {
|
266
|
+
// Get rid of CSS classes associated with the active ("fake horizontal caret") mode from the view widget.
|
267
|
+
writer.removeClass(POSSIBLE_INSERTION_POSITIONS.map(positionToWidgetCssClass), selectedViewElement);
|
268
|
+
this._currentFakeCaretModelElement = null;
|
269
|
+
}
|
270
|
+
}
|
271
|
+
const selectedModelElement = data.selection.getSelectedElement();
|
272
|
+
if (!selectedModelElement) {
|
273
|
+
return;
|
274
|
+
}
|
275
|
+
const selectedViewElement = conversionApi.mapper.toViewElement(selectedModelElement);
|
276
|
+
if (!isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
|
277
|
+
return;
|
278
|
+
}
|
279
|
+
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(data.selection);
|
280
|
+
if (!typeAroundFakeCaretPosition) {
|
281
|
+
return;
|
282
|
+
}
|
283
|
+
writer.addClass(positionToWidgetCssClass(typeAroundFakeCaretPosition), selectedViewElement);
|
284
|
+
// Remember the view widget that got the "fake-caret" CSS class. This class should be removed ASAP when the
|
285
|
+
// selection changes
|
286
|
+
this._currentFakeCaretModelElement = selectedModelElement;
|
287
|
+
});
|
288
|
+
this._listenToIfEnabled(editor.ui.focusTracker, 'change:isFocused', (evt, name, isFocused) => {
|
289
|
+
if (!isFocused) {
|
290
|
+
editor.model.change(writer => {
|
291
|
+
writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
|
292
|
+
});
|
293
|
+
}
|
294
|
+
});
|
295
|
+
function positionToWidgetCssClass(position) {
|
296
|
+
return `ck-widget_type-around_show-fake-caret_${position}`;
|
297
|
+
}
|
298
|
+
}
|
299
|
+
/**
|
300
|
+
* A listener executed on each "keydown" in the view document, a part of
|
301
|
+
* {@link #_enableTypeAroundFakeCaretActivationUsingKeyboardArrows}.
|
302
|
+
*
|
303
|
+
* It decides whether the arrow keypress should activate the fake caret or not (also whether it should
|
304
|
+
* be deactivated).
|
305
|
+
*
|
306
|
+
* The fake caret activation is done by setting the `widget-type-around` model selection attribute
|
307
|
+
* in this listener, and stopping and preventing the event that would normally be handled by the widget
|
308
|
+
* plugin that is responsible for the regular keyboard navigation near/across all widgets (that
|
309
|
+
* includes inline widgets, which are ignored by the widget type around plugin).
|
310
|
+
*/
|
311
|
+
_handleArrowKeyPress(evt, domEventData) {
|
312
|
+
const editor = this.editor;
|
313
|
+
const model = editor.model;
|
314
|
+
const modelSelection = model.document.selection;
|
315
|
+
const schema = model.schema;
|
316
|
+
const editingView = editor.editing.view;
|
317
|
+
const keyCode = domEventData.keyCode;
|
318
|
+
const isForward = isForwardArrowKeyCode(keyCode, editor.locale.contentLanguageDirection);
|
319
|
+
const selectedViewElement = editingView.document.selection.getSelectedElement();
|
320
|
+
const selectedModelElement = editor.editing.mapper.toModelElement(selectedViewElement);
|
321
|
+
let shouldStopAndPreventDefault;
|
322
|
+
// Handle keyboard navigation when a type-around-compatible widget is currently selected.
|
323
|
+
if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
|
324
|
+
shouldStopAndPreventDefault = this._handleArrowKeyPressOnSelectedWidget(isForward);
|
325
|
+
}
|
326
|
+
// Handle keyboard arrow navigation when the selection is next to a type-around-compatible widget
|
327
|
+
// and the widget is about to be selected.
|
328
|
+
else if (modelSelection.isCollapsed) {
|
329
|
+
shouldStopAndPreventDefault = this._handleArrowKeyPressWhenSelectionNextToAWidget(isForward);
|
330
|
+
}
|
331
|
+
// Handle collapsing a non-collapsed selection that is wider than on a single widget.
|
332
|
+
else if (!domEventData.shiftKey) {
|
333
|
+
shouldStopAndPreventDefault = this._handleArrowKeyPressWhenNonCollapsedSelection(isForward);
|
334
|
+
}
|
335
|
+
if (shouldStopAndPreventDefault) {
|
336
|
+
domEventData.preventDefault();
|
337
|
+
evt.stop();
|
338
|
+
}
|
339
|
+
}
|
340
|
+
/**
|
341
|
+
* Handles the keyboard navigation on "keydown" when a widget is currently selected and activates or deactivates
|
342
|
+
* the fake caret for that widget, depending on the current value of the `widget-type-around` model
|
343
|
+
* selection attribute and the direction of the pressed arrow key.
|
344
|
+
*
|
345
|
+
* @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
|
346
|
+
* as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
|
347
|
+
* @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
|
348
|
+
* process the event any further. Returns `false` otherwise.
|
349
|
+
*/
|
350
|
+
_handleArrowKeyPressOnSelectedWidget(isForward) {
|
351
|
+
const editor = this.editor;
|
352
|
+
const model = editor.model;
|
353
|
+
const modelSelection = model.document.selection;
|
354
|
+
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection);
|
355
|
+
return model.change(writer => {
|
356
|
+
// If the fake caret is displayed...
|
357
|
+
if (typeAroundFakeCaretPosition) {
|
358
|
+
const isLeavingWidget = typeAroundFakeCaretPosition === (isForward ? 'after' : 'before');
|
359
|
+
// If the keyboard arrow works against the value of the selection attribute...
|
360
|
+
// then remove the selection attribute but prevent default DOM actions
|
361
|
+
// and do not let the Widget plugin listener move the selection. This brings
|
362
|
+
// the widget back to the state, for instance, like if was selected using the mouse.
|
363
|
+
//
|
364
|
+
// **Note**: If leaving the widget when the fake caret is active, then the default
|
365
|
+
// Widget handler will change the selection and, in turn, this will automatically discard
|
366
|
+
// the selection attribute.
|
367
|
+
if (!isLeavingWidget) {
|
368
|
+
writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
|
369
|
+
return true;
|
370
|
+
}
|
371
|
+
}
|
372
|
+
// If the fake caret wasn't displayed, let's set it now according to the direction of the arrow
|
373
|
+
// key press. This also means we cannot let the Widget plugin listener move the selection.
|
374
|
+
else {
|
375
|
+
writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before');
|
376
|
+
return true;
|
377
|
+
}
|
378
|
+
return false;
|
379
|
+
});
|
380
|
+
}
|
381
|
+
/**
|
382
|
+
* Handles the keyboard navigation on "keydown" when **no** widget is selected but the selection is **directly** next
|
383
|
+
* to one and upon the fake caret should become active for this widget upon arrow keypress
|
384
|
+
* (AKA entering/selecting the widget).
|
385
|
+
*
|
386
|
+
* **Note**: This code mirrors the implementation from the widget plugin but also adds the selection attribute.
|
387
|
+
* Unfortunately, there is no safe way to let the widget plugin do the selection part first and then just set the
|
388
|
+
* selection attribute here in the widget type around plugin. This is why this code must duplicate some from the widget plugin.
|
389
|
+
*
|
390
|
+
* @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
|
391
|
+
* as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
|
392
|
+
* @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
|
393
|
+
* process the event any further. Returns `false` otherwise.
|
394
|
+
*/
|
395
|
+
_handleArrowKeyPressWhenSelectionNextToAWidget(isForward) {
|
396
|
+
const editor = this.editor;
|
397
|
+
const model = editor.model;
|
398
|
+
const schema = model.schema;
|
399
|
+
const widgetPlugin = editor.plugins.get('Widget');
|
400
|
+
// This is the widget the selection is about to be set on.
|
401
|
+
const modelElementNextToSelection = widgetPlugin._getObjectElementNextToSelection(isForward);
|
402
|
+
const viewElementNextToSelection = editor.editing.mapper.toViewElement(modelElementNextToSelection);
|
403
|
+
if (isTypeAroundWidget(viewElementNextToSelection, modelElementNextToSelection, schema)) {
|
404
|
+
model.change(writer => {
|
405
|
+
widgetPlugin._setSelectionOverElement(modelElementNextToSelection);
|
406
|
+
writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'before' : 'after');
|
407
|
+
});
|
408
|
+
// The change() block above does the same job as the Widget plugin. The event can
|
409
|
+
// be safely canceled.
|
410
|
+
return true;
|
411
|
+
}
|
412
|
+
return false;
|
413
|
+
}
|
414
|
+
/**
|
415
|
+
* Handles the keyboard navigation on "keydown" when a widget is currently selected (together with some other content)
|
416
|
+
* and the widget is the first or last element in the selection. It activates or deactivates the fake caret for that widget.
|
417
|
+
*
|
418
|
+
* @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
|
419
|
+
* as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
|
420
|
+
* @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
|
421
|
+
* process the event any further. Returns `false` otherwise.
|
422
|
+
*/
|
423
|
+
_handleArrowKeyPressWhenNonCollapsedSelection(isForward) {
|
424
|
+
const editor = this.editor;
|
425
|
+
const model = editor.model;
|
426
|
+
const schema = model.schema;
|
427
|
+
const mapper = editor.editing.mapper;
|
428
|
+
const modelSelection = model.document.selection;
|
429
|
+
const selectedModelNode = isForward ?
|
430
|
+
modelSelection.getLastPosition().nodeBefore :
|
431
|
+
modelSelection.getFirstPosition().nodeAfter;
|
432
|
+
const selectedViewNode = mapper.toViewElement(selectedModelNode);
|
433
|
+
// There is a widget at the collapse position so collapse the selection to the fake caret on it.
|
434
|
+
if (isTypeAroundWidget(selectedViewNode, selectedModelNode, schema)) {
|
435
|
+
model.change(writer => {
|
436
|
+
writer.setSelection(selectedModelNode, 'on');
|
437
|
+
writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before');
|
438
|
+
});
|
439
|
+
return true;
|
440
|
+
}
|
441
|
+
return false;
|
442
|
+
}
|
443
|
+
/**
|
444
|
+
* Registers a `mousedown` listener for the view document which intercepts events
|
445
|
+
* coming from the widget type around UI, which happens when a user clicks one of the buttons
|
446
|
+
* that insert a paragraph next to a widget.
|
447
|
+
*/
|
448
|
+
_enableInsertingParagraphsOnButtonClick() {
|
449
|
+
const editor = this.editor;
|
450
|
+
const editingView = editor.editing.view;
|
451
|
+
this._listenToIfEnabled(editingView.document, 'mousedown', (evt, domEventData) => {
|
452
|
+
const button = getClosestTypeAroundDomButton(domEventData.domTarget);
|
453
|
+
if (!button) {
|
454
|
+
return;
|
455
|
+
}
|
456
|
+
const buttonPosition = getTypeAroundButtonPosition(button);
|
457
|
+
const widgetViewElement = getClosestWidgetViewElement(button, editingView.domConverter);
|
458
|
+
const widgetModelElement = editor.editing.mapper.toModelElement(widgetViewElement);
|
459
|
+
this._insertParagraph(widgetModelElement, buttonPosition);
|
460
|
+
domEventData.preventDefault();
|
461
|
+
evt.stop();
|
462
|
+
});
|
463
|
+
}
|
464
|
+
/**
|
465
|
+
* Creates the <kbd>Enter</kbd> key listener on the view document that allows the user to insert a paragraph
|
466
|
+
* near the widget when either:
|
467
|
+
*
|
468
|
+
* * The fake caret was first activated using the arrow keys,
|
469
|
+
* * The entire widget is selected in the model.
|
470
|
+
*
|
471
|
+
* In the first case, the new paragraph is inserted according to the `widget-type-around` selection
|
472
|
+
* attribute (see {@link #_handleArrowKeyPress}).
|
473
|
+
*
|
474
|
+
* In the second case, the new paragraph is inserted based on whether a soft (<kbd>Shift</kbd>+<kbd>Enter</kbd>) keystroke
|
475
|
+
* was pressed or not.
|
476
|
+
*/
|
477
|
+
_enableInsertingParagraphsOnEnterKeypress() {
|
478
|
+
const editor = this.editor;
|
479
|
+
const selection = editor.model.document.selection;
|
480
|
+
const editingView = editor.editing.view;
|
481
|
+
this._listenToIfEnabled(editingView.document, 'enter', (evt, domEventData) => {
|
482
|
+
// This event could be triggered from inside the widget but we are interested
|
483
|
+
// only when the widget is selected itself.
|
484
|
+
if (evt.eventPhase != 'atTarget') {
|
485
|
+
return;
|
486
|
+
}
|
487
|
+
const selectedModelElement = selection.getSelectedElement();
|
488
|
+
const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
|
489
|
+
const schema = editor.model.schema;
|
490
|
+
let wasHandled;
|
491
|
+
// First check if the widget is selected and there's a type around selection attribute associated
|
492
|
+
// with the fake caret that would tell where to insert a new paragraph.
|
493
|
+
if (this._insertParagraphAccordingToFakeCaretPosition()) {
|
494
|
+
wasHandled = true;
|
495
|
+
}
|
496
|
+
// Then, if there is no selection attribute associated with the fake caret, check if the widget
|
497
|
+
// simply is selected and create a new paragraph according to the keystroke (Shift+)Enter.
|
498
|
+
else if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) {
|
499
|
+
this._insertParagraph(selectedModelElement, domEventData.isSoft ? 'before' : 'after');
|
500
|
+
wasHandled = true;
|
501
|
+
}
|
502
|
+
if (wasHandled) {
|
503
|
+
domEventData.preventDefault();
|
504
|
+
evt.stop();
|
505
|
+
}
|
506
|
+
}, { context: isWidget });
|
507
|
+
}
|
508
|
+
/**
|
509
|
+
* Similar to the {@link #_enableInsertingParagraphsOnEnterKeypress}, it allows the user
|
510
|
+
* to insert a paragraph next to a widget when the fake caret was activated using arrow
|
511
|
+
* keys but it responds to typing instead of <kbd>Enter</kbd>.
|
512
|
+
*
|
513
|
+
* Listener enabled by this method will insert a new paragraph according to the `widget-type-around`
|
514
|
+
* model selection attribute as the user simply starts typing, which creates the impression that the fake caret
|
515
|
+
* behaves like a real one rendered by the browser (AKA your text appears where the caret was).
|
516
|
+
*
|
517
|
+
* **Note**: At the moment this listener creates 2 undo steps: one for the `insertParagraph` command
|
518
|
+
* and another one for actual typing. It is not a disaster but this may need to be fixed
|
519
|
+
* sooner or later.
|
520
|
+
*/
|
521
|
+
_enableInsertingParagraphsOnTypingKeystroke() {
|
522
|
+
const editor = this.editor;
|
523
|
+
const viewDocument = editor.editing.view.document;
|
524
|
+
// Note: The priority must precede the default Input plugin insertText handler.
|
525
|
+
this._listenToIfEnabled(viewDocument, 'insertText', (evt, data) => {
|
526
|
+
if (this._insertParagraphAccordingToFakeCaretPosition()) {
|
527
|
+
// The view selection in the event data contains the widget. If the new paragraph
|
528
|
+
// was inserted, modify the view selection passed along with the insertText event
|
529
|
+
// so the default event handler in the Input plugin starts typing inside the paragraph.
|
530
|
+
// Otherwise, the typing would be over the widget.
|
531
|
+
data.selection = viewDocument.selection;
|
532
|
+
}
|
533
|
+
}, { priority: 'high' });
|
534
|
+
if (env.isAndroid) {
|
535
|
+
// On Android with English keyboard, the composition starts just by putting caret
|
536
|
+
// at the word end or by selecting a table column. This is not a real composition started.
|
537
|
+
// Trigger delete content on first composition key pressed.
|
538
|
+
this._listenToIfEnabled(viewDocument, 'keydown', (evt, data) => {
|
539
|
+
if (data.keyCode == 229) {
|
540
|
+
this._insertParagraphAccordingToFakeCaretPosition();
|
541
|
+
}
|
542
|
+
});
|
543
|
+
}
|
544
|
+
else {
|
545
|
+
// Note: The priority must precede the default Input plugin compositionstart handler (to call it before delete content).
|
546
|
+
this._listenToIfEnabled(viewDocument, 'compositionstart', () => {
|
547
|
+
this._insertParagraphAccordingToFakeCaretPosition();
|
548
|
+
}, { priority: 'high' });
|
549
|
+
}
|
550
|
+
}
|
551
|
+
/**
|
552
|
+
* It creates a "delete" event listener on the view document to handle cases when the <kbd>Delete</kbd> or <kbd>Backspace</kbd>
|
553
|
+
* is pressed and the fake caret is currently active.
|
554
|
+
*
|
555
|
+
* The fake caret should create an illusion of a real browser caret so that when it appears before or after
|
556
|
+
* a widget, pressing <kbd>Delete</kbd> or <kbd>Backspace</kbd> should remove a widget or delete the content
|
557
|
+
* before or after a widget (depending on the content surrounding the widget).
|
558
|
+
*/
|
559
|
+
_enableDeleteIntegration() {
|
560
|
+
const editor = this.editor;
|
561
|
+
const editingView = editor.editing.view;
|
562
|
+
const model = editor.model;
|
563
|
+
const schema = model.schema;
|
564
|
+
this._listenToIfEnabled(editingView.document, 'delete', (evt, domEventData) => {
|
565
|
+
// This event could be triggered from inside the widget but we are interested
|
566
|
+
// only when the widget is selected itself.
|
567
|
+
if (evt.eventPhase != 'atTarget') {
|
568
|
+
return;
|
569
|
+
}
|
570
|
+
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(model.document.selection);
|
571
|
+
// This listener handles only these cases when the fake caret is active.
|
572
|
+
if (!typeAroundFakeCaretPosition) {
|
573
|
+
return;
|
574
|
+
}
|
575
|
+
const direction = domEventData.direction;
|
576
|
+
const selectedModelWidget = model.document.selection.getSelectedElement();
|
577
|
+
const isFakeCaretBefore = typeAroundFakeCaretPosition === 'before';
|
578
|
+
const isDeleteForward = direction == 'forward';
|
579
|
+
const shouldDeleteEntireWidget = isFakeCaretBefore === isDeleteForward;
|
580
|
+
if (shouldDeleteEntireWidget) {
|
581
|
+
editor.execute('delete', {
|
582
|
+
selection: model.createSelection(selectedModelWidget, 'on')
|
583
|
+
});
|
584
|
+
}
|
585
|
+
else {
|
586
|
+
const range = schema.getNearestSelectionRange(model.createPositionAt(selectedModelWidget, typeAroundFakeCaretPosition), direction);
|
587
|
+
// If there is somewhere to move selection to, then there will be something to delete.
|
588
|
+
if (range) {
|
589
|
+
// If the range is NOT collapsed, then we know that the range contains an object (see getNearestSelectionRange() docs).
|
590
|
+
if (!range.isCollapsed) {
|
591
|
+
model.change(writer => {
|
592
|
+
writer.setSelection(range);
|
593
|
+
editor.execute(isDeleteForward ? 'deleteForward' : 'delete');
|
594
|
+
});
|
595
|
+
}
|
596
|
+
else {
|
597
|
+
const probe = model.createSelection(range.start);
|
598
|
+
model.modifySelection(probe, { direction });
|
599
|
+
// If the range is collapsed, let's see if a non-collapsed range exists that can could be deleted.
|
600
|
+
// If such range exists, use the editor command because it it safe for collaboration (it merges where it can).
|
601
|
+
if (!probe.focus.isEqual(range.start)) {
|
602
|
+
model.change(writer => {
|
603
|
+
writer.setSelection(range);
|
604
|
+
editor.execute(isDeleteForward ? 'deleteForward' : 'delete');
|
605
|
+
});
|
606
|
+
}
|
607
|
+
// If there is no non-collapsed range to be deleted then we are sure that there is an empty element
|
608
|
+
// next to a widget that should be removed. "delete" and "deleteForward" commands cannot get rid of it
|
609
|
+
// so calling Model#deleteContent here manually.
|
610
|
+
else {
|
611
|
+
const deepestEmptyRangeAncestor = getDeepestEmptyElementAncestor(schema, range.start.parent);
|
612
|
+
model.deleteContent(model.createSelection(deepestEmptyRangeAncestor, 'on'), {
|
613
|
+
doNotAutoparagraph: true
|
614
|
+
});
|
615
|
+
}
|
616
|
+
}
|
617
|
+
}
|
618
|
+
}
|
619
|
+
// If some content was deleted, don't let the handler from the Widget plugin kick in.
|
620
|
+
// If nothing was deleted, then the default handler will have nothing to do anyway.
|
621
|
+
domEventData.preventDefault();
|
622
|
+
evt.stop();
|
623
|
+
}, { context: isWidget });
|
624
|
+
}
|
625
|
+
/**
|
626
|
+
* Attaches the {@link module:engine/model/model~Model#event:insertContent} event listener that, for instance, allows the user to paste
|
627
|
+
* content near a widget when the fake caret is first activated using the arrow keys.
|
628
|
+
*
|
629
|
+
* The content is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
|
630
|
+
*/
|
631
|
+
_enableInsertContentIntegration() {
|
632
|
+
const editor = this.editor;
|
633
|
+
const model = this.editor.model;
|
634
|
+
const documentSelection = model.document.selection;
|
635
|
+
this._listenToIfEnabled(editor.model, 'insertContent', (evt, [content, selectable]) => {
|
636
|
+
if (selectable && !selectable.is('documentSelection')) {
|
637
|
+
return;
|
638
|
+
}
|
639
|
+
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
|
640
|
+
if (!typeAroundFakeCaretPosition) {
|
641
|
+
return;
|
642
|
+
}
|
643
|
+
evt.stop();
|
644
|
+
return model.change(writer => {
|
645
|
+
const selectedElement = documentSelection.getSelectedElement();
|
646
|
+
const position = model.createPositionAt(selectedElement, typeAroundFakeCaretPosition);
|
647
|
+
const selection = writer.createSelection(position);
|
648
|
+
const result = model.insertContent(content, selection);
|
649
|
+
writer.setSelection(selection);
|
650
|
+
return result;
|
651
|
+
});
|
652
|
+
}, { priority: 'high' });
|
653
|
+
}
|
654
|
+
/**
|
655
|
+
* Attaches the {@link module:engine/model/model~Model#event:insertObject} event listener that modifies the
|
656
|
+
* `options.findOptimalPosition`parameter to position of fake caret in relation to selected element
|
657
|
+
* to reflect user's intent of desired insertion position.
|
658
|
+
*
|
659
|
+
* The object is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
|
660
|
+
*/
|
661
|
+
_enableInsertObjectIntegration() {
|
662
|
+
const editor = this.editor;
|
663
|
+
const model = this.editor.model;
|
664
|
+
const documentSelection = model.document.selection;
|
665
|
+
this._listenToIfEnabled(editor.model, 'insertObject', (evt, args) => {
|
666
|
+
const [, selectable, options = {}] = args;
|
667
|
+
if (selectable && !selectable.is('documentSelection')) {
|
668
|
+
return;
|
669
|
+
}
|
670
|
+
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
|
671
|
+
if (!typeAroundFakeCaretPosition) {
|
672
|
+
return;
|
673
|
+
}
|
674
|
+
options.findOptimalPosition = typeAroundFakeCaretPosition;
|
675
|
+
args[3] = options;
|
676
|
+
}, { priority: 'high' });
|
677
|
+
}
|
678
|
+
/**
|
679
|
+
* Attaches the {@link module:engine/model/model~Model#event:deleteContent} event listener to block the event when the fake
|
680
|
+
* caret is active.
|
681
|
+
*
|
682
|
+
* This is required for cases that trigger {@link module:engine/model/model~Model#deleteContent `model.deleteContent()`}
|
683
|
+
* before calling {@link module:engine/model/model~Model#insertContent `model.insertContent()`} like, for instance,
|
684
|
+
* plain text pasting.
|
685
|
+
*/
|
686
|
+
_enableDeleteContentIntegration() {
|
687
|
+
const editor = this.editor;
|
688
|
+
const model = this.editor.model;
|
689
|
+
const documentSelection = model.document.selection;
|
690
|
+
this._listenToIfEnabled(editor.model, 'deleteContent', (evt, [selection]) => {
|
691
|
+
if (selection && !selection.is('documentSelection')) {
|
692
|
+
return;
|
693
|
+
}
|
694
|
+
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
|
695
|
+
// Disable removing the selection content while pasting plain text.
|
696
|
+
if (typeAroundFakeCaretPosition) {
|
697
|
+
evt.stop();
|
698
|
+
}
|
699
|
+
}, { priority: 'high' });
|
700
|
+
}
|
701
|
+
}
|
702
|
+
/**
|
703
|
+
* Injects the type around UI into a view widget instance.
|
704
|
+
*/
|
705
|
+
function injectUIIntoWidget(viewWriter, buttonTitles, widgetViewElement) {
|
706
|
+
const typeAroundWrapper = viewWriter.createUIElement('div', {
|
707
|
+
class: 'ck ck-reset_all ck-widget__type-around'
|
708
|
+
}, function (domDocument) {
|
709
|
+
const wrapperDomElement = this.toDomElement(domDocument);
|
710
|
+
injectButtons(wrapperDomElement, buttonTitles);
|
711
|
+
injectFakeCaret(wrapperDomElement);
|
712
|
+
return wrapperDomElement;
|
713
|
+
});
|
714
|
+
// Inject the type around wrapper into the widget's wrapper.
|
715
|
+
viewWriter.insert(viewWriter.createPositionAt(widgetViewElement, 'end'), typeAroundWrapper);
|
716
|
+
}
|
717
|
+
/**
|
718
|
+
* FYI: Not using the IconView class because each instance would need to be destroyed to avoid memory leaks
|
719
|
+
* and it's pretty hard to figure out when a view (widget) is gone for good so it's cheaper to use raw
|
720
|
+
* <svg> here.
|
721
|
+
*/
|
722
|
+
function injectButtons(wrapperDomElement, buttonTitles) {
|
723
|
+
for (const position of POSSIBLE_INSERTION_POSITIONS) {
|
724
|
+
const buttonTemplate = new Template({
|
725
|
+
tag: 'div',
|
726
|
+
attributes: {
|
727
|
+
class: [
|
728
|
+
'ck',
|
729
|
+
'ck-widget__type-around__button',
|
730
|
+
`ck-widget__type-around__button_${position}`
|
731
|
+
],
|
732
|
+
title: buttonTitles[position],
|
733
|
+
'aria-hidden': 'true'
|
734
|
+
},
|
735
|
+
children: [
|
736
|
+
wrapperDomElement.ownerDocument.importNode(RETURN_ARROW_ICON_ELEMENT, true)
|
737
|
+
]
|
738
|
+
});
|
739
|
+
wrapperDomElement.appendChild(buttonTemplate.render());
|
740
|
+
}
|
741
|
+
}
|
742
|
+
function injectFakeCaret(wrapperDomElement) {
|
743
|
+
const caretTemplate = new Template({
|
744
|
+
tag: 'div',
|
745
|
+
attributes: {
|
746
|
+
class: [
|
747
|
+
'ck',
|
748
|
+
'ck-widget__type-around__fake-caret'
|
749
|
+
]
|
750
|
+
}
|
751
|
+
});
|
752
|
+
wrapperDomElement.appendChild(caretTemplate.render());
|
753
|
+
}
|
754
|
+
/**
|
755
|
+
* Returns the ancestor of an element closest to the root which is empty. For instance,
|
756
|
+
* for `<baz>`:
|
757
|
+
*
|
758
|
+
* ```
|
759
|
+
* <foo>abc<bar><baz></baz></bar></foo>
|
760
|
+
* ```
|
761
|
+
*
|
762
|
+
* it returns `<bar>`.
|
763
|
+
*/
|
764
|
+
function getDeepestEmptyElementAncestor(schema, element) {
|
765
|
+
let deepestEmptyAncestor = element;
|
766
|
+
for (const ancestor of element.getAncestors({ parentFirst: true })) {
|
767
|
+
if (ancestor.childCount > 1 || schema.isLimit(ancestor)) {
|
768
|
+
break;
|
769
|
+
}
|
770
|
+
deepestEmptyAncestor = ancestor;
|
771
|
+
}
|
772
|
+
return deepestEmptyAncestor;
|
773
|
+
}
|