@ckeditor/ckeditor5-widget 40.0.0 → 40.2.0

Sign up to get free protection for your applications and to get access to all the features.
package/src/widget.js CHANGED
@@ -1,380 +1,429 @@
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
- /**
6
- * @module widget/widget
7
- */
8
- import { Plugin } from '@ckeditor/ckeditor5-core';
9
- import { MouseObserver } from '@ckeditor/ckeditor5-engine';
10
- import { Delete } from '@ckeditor/ckeditor5-typing';
11
- import { env, getLocalizedArrowKeyCodeDirection } from '@ckeditor/ckeditor5-utils';
12
- import WidgetTypeAround from './widgettypearound/widgettypearound';
13
- import verticalNavigationHandler from './verticalnavigation';
14
- import { getLabel, isWidget, WIDGET_SELECTED_CLASS_NAME } from './utils';
15
- import '../theme/widget.css';
16
- /**
17
- * The widget plugin. It enables base support for widgets.
18
- *
19
- * See {@glink api/widget package page} for more details and documentation.
20
- *
21
- * This plugin enables multiple behaviors required by widgets:
22
- *
23
- * * The model to view selection converter for the editing pipeline (it handles widget custom selection rendering).
24
- * If a converted selection wraps around a widget element, that selection is marked as
25
- * {@link module:engine/view/selection~Selection#isFake fake}. Additionally, the `ck-widget_selected` CSS class
26
- * is added to indicate that widget has been selected.
27
- * * The mouse and keyboard events handling on and around widget elements.
28
- */
29
- export default class Widget extends Plugin {
30
- constructor() {
31
- super(...arguments);
32
- /**
33
- * Holds previously selected widgets.
34
- */
35
- this._previouslySelected = new Set();
36
- }
37
- /**
38
- * @inheritDoc
39
- */
40
- static get pluginName() {
41
- return 'Widget';
42
- }
43
- /**
44
- * @inheritDoc
45
- */
46
- static get requires() {
47
- return [WidgetTypeAround, Delete];
48
- }
49
- /**
50
- * @inheritDoc
51
- */
52
- init() {
53
- const editor = this.editor;
54
- const view = editor.editing.view;
55
- const viewDocument = view.document;
56
- // Model to view selection converter.
57
- // Converts selection placed over widget element to fake selection.
58
- //
59
- // By default, the selection is downcasted by the engine to surround the attribute element, even though its only
60
- // child is an inline widget. A similar thing also happens when a collapsed marker is rendered as a UI element
61
- // next to an inline widget: the view selection contains both the widget and the marker.
62
- //
63
- // This prevents creating a correct fake selection when this inline widget is selected. Normalize the selection
64
- // in these cases based on the model:
65
- //
66
- // [<attributeElement><inlineWidget /></attributeElement>] -> <attributeElement>[<inlineWidget />]</attributeElement>
67
- // [<uiElement></uiElement><inlineWidget />] -> <uiElement></uiElement>[<inlineWidget />]
68
- //
69
- // Thanks to this:
70
- //
71
- // * fake selection can be set correctly,
72
- // * any logic depending on (View)Selection#getSelectedElement() also works OK.
73
- //
74
- // See https://github.com/ckeditor/ckeditor5/issues/9524.
75
- this.editor.editing.downcastDispatcher.on('selection', (evt, data, conversionApi) => {
76
- const viewWriter = conversionApi.writer;
77
- const modelSelection = data.selection;
78
- // The collapsed selection can't contain any widget.
79
- if (modelSelection.isCollapsed) {
80
- return;
81
- }
82
- const selectedModelElement = modelSelection.getSelectedElement();
83
- if (!selectedModelElement) {
84
- return;
85
- }
86
- const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
87
- if (!isWidget(selectedViewElement)) {
88
- return;
89
- }
90
- if (!conversionApi.consumable.consume(modelSelection, 'selection')) {
91
- return;
92
- }
93
- viewWriter.setSelection(viewWriter.createRangeOn(selectedViewElement), {
94
- fake: true,
95
- label: getLabel(selectedViewElement)
96
- });
97
- });
98
- // Mark all widgets inside the selection with the css class.
99
- // This handler is registered at the 'low' priority so it's triggered after the real selection conversion.
100
- this.editor.editing.downcastDispatcher.on('selection', (evt, data, conversionApi) => {
101
- // Remove selected class from previously selected widgets.
102
- this._clearPreviouslySelectedWidgets(conversionApi.writer);
103
- const viewWriter = conversionApi.writer;
104
- const viewSelection = viewWriter.document.selection;
105
- let lastMarked = null;
106
- for (const range of viewSelection.getRanges()) {
107
- // Note: There could be multiple selected widgets in a range but no fake selection.
108
- // All of them must be marked as selected, for instance [<widget></widget><widget></widget>]
109
- for (const value of range) {
110
- const node = value.item;
111
- // Do not mark nested widgets in selected one. See: #4594
112
- if (isWidget(node) && !isChild(node, lastMarked)) {
113
- viewWriter.addClass(WIDGET_SELECTED_CLASS_NAME, node);
114
- this._previouslySelected.add(node);
115
- lastMarked = node;
116
- }
117
- }
118
- }
119
- }, { priority: 'low' });
120
- // If mouse down is pressed on widget - create selection over whole widget.
121
- view.addObserver(MouseObserver);
122
- this.listenTo(viewDocument, 'mousedown', (...args) => this._onMousedown(...args));
123
- // There are two keydown listeners working on different priorities. This allows other
124
- // features such as WidgetTypeAround or TableKeyboard to attach their listeners in between
125
- // and customize the behavior even further in different content/selection scenarios.
126
- //
127
- // * The first listener handles changing the selection on arrow key press
128
- // if the widget is selected or if the selection is next to a widget and the widget
129
- // should become selected upon the arrow key press.
130
- //
131
- // * The second (late) listener makes sure the default browser action on arrow key press is
132
- // prevented when a widget is selected. This prevents the selection from being moved
133
- // from a fake selection container.
134
- this.listenTo(viewDocument, 'arrowKey', (...args) => {
135
- this._handleSelectionChangeOnArrowKeyPress(...args);
136
- }, { context: [isWidget, '$text'] });
137
- this.listenTo(viewDocument, 'arrowKey', (...args) => {
138
- this._preventDefaultOnArrowKeyPress(...args);
139
- }, { context: '$root' });
140
- this.listenTo(viewDocument, 'arrowKey', verticalNavigationHandler(this.editor.editing), { context: '$text' });
141
- // Handle custom delete behaviour.
142
- this.listenTo(viewDocument, 'delete', (evt, data) => {
143
- if (this._handleDelete(data.direction == 'forward')) {
144
- data.preventDefault();
145
- evt.stop();
146
- }
147
- }, { context: '$root' });
148
- }
149
- /**
150
- * Handles {@link module:engine/view/document~Document#event:mousedown mousedown} events on widget elements.
151
- */
152
- _onMousedown(eventInfo, domEventData) {
153
- const editor = this.editor;
154
- const view = editor.editing.view;
155
- const viewDocument = view.document;
156
- let element = domEventData.target;
157
- // Do nothing for single or double click inside nested editable.
158
- if (isInsideNestedEditable(element)) {
159
- // But at least triple click inside nested editable causes broken selection in Safari.
160
- // For such event, we select the entire nested editable element.
161
- // See: https://github.com/ckeditor/ckeditor5/issues/1463.
162
- if ((env.isSafari || env.isGecko) && domEventData.domEvent.detail >= 3) {
163
- const mapper = editor.editing.mapper;
164
- const viewElement = element.is('attributeElement') ?
165
- element.findAncestor(element => !element.is('attributeElement')) : element;
166
- const modelElement = mapper.toModelElement(viewElement);
167
- domEventData.preventDefault();
168
- this.editor.model.change(writer => {
169
- writer.setSelection(modelElement, 'in');
170
- });
171
- }
172
- return;
173
- }
174
- // If target is not a widget element - check if one of the ancestors is.
175
- if (!isWidget(element)) {
176
- element = element.findAncestor(isWidget);
177
- if (!element) {
178
- return;
179
- }
180
- }
181
- // On Android selection would jump to the first table cell, on other devices
182
- // we can't block it (and don't need to) because of drag and drop support.
183
- if (env.isAndroid) {
184
- domEventData.preventDefault();
185
- }
186
- // Focus editor if is not focused already.
187
- if (!viewDocument.isFocused) {
188
- view.focus();
189
- }
190
- // Create model selection over widget.
191
- const modelElement = editor.editing.mapper.toModelElement(element);
192
- this._setSelectionOverElement(modelElement);
193
- }
194
- /**
195
- * Handles {@link module:engine/view/document~Document#event:keydown keydown} events and changes
196
- * the model selection when:
197
- *
198
- * * arrow key is pressed when the widget is selected,
199
- * * the selection is next to a widget and the widget should become selected upon the arrow key press.
200
- *
201
- * See {@link #_preventDefaultOnArrowKeyPress}.
202
- */
203
- _handleSelectionChangeOnArrowKeyPress(eventInfo, domEventData) {
204
- const keyCode = domEventData.keyCode;
205
- const model = this.editor.model;
206
- const schema = model.schema;
207
- const modelSelection = model.document.selection;
208
- const objectElement = modelSelection.getSelectedElement();
209
- const direction = getLocalizedArrowKeyCodeDirection(keyCode, this.editor.locale.contentLanguageDirection);
210
- const isForward = direction == 'down' || direction == 'right';
211
- const isVerticalNavigation = direction == 'up' || direction == 'down';
212
- // If object element is selected.
213
- if (objectElement && schema.isObject(objectElement)) {
214
- const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition();
215
- const newRange = schema.getNearestSelectionRange(position, isForward ? 'forward' : 'backward');
216
- if (newRange) {
217
- model.change(writer => {
218
- writer.setSelection(newRange);
219
- });
220
- domEventData.preventDefault();
221
- eventInfo.stop();
222
- }
223
- return;
224
- }
225
- // Handle collapsing of the selection when there is any widget on the edge of selection.
226
- // This is needed because browsers have problems with collapsing such selection.
227
- if (!modelSelection.isCollapsed && !domEventData.shiftKey) {
228
- const firstPosition = modelSelection.getFirstPosition();
229
- const lastPosition = modelSelection.getLastPosition();
230
- const firstSelectedNode = firstPosition.nodeAfter;
231
- const lastSelectedNode = lastPosition.nodeBefore;
232
- if (firstSelectedNode && schema.isObject(firstSelectedNode) || lastSelectedNode && schema.isObject(lastSelectedNode)) {
233
- model.change(writer => {
234
- writer.setSelection(isForward ? lastPosition : firstPosition);
235
- });
236
- domEventData.preventDefault();
237
- eventInfo.stop();
238
- }
239
- return;
240
- }
241
- // Return if not collapsed.
242
- if (!modelSelection.isCollapsed) {
243
- return;
244
- }
245
- // If selection is next to object element.
246
- const objectElementNextToSelection = this._getObjectElementNextToSelection(isForward);
247
- if (objectElementNextToSelection && schema.isObject(objectElementNextToSelection)) {
248
- // Do not select an inline widget while handling up/down arrow.
249
- if (schema.isInline(objectElementNextToSelection) && isVerticalNavigation) {
250
- return;
251
- }
252
- this._setSelectionOverElement(objectElementNextToSelection);
253
- domEventData.preventDefault();
254
- eventInfo.stop();
255
- }
256
- }
257
- /**
258
- * Handles {@link module:engine/view/document~Document#event:keydown keydown} events and prevents
259
- * the default browser behavior to make sure the fake selection is not being moved from a fake selection
260
- * container.
261
- *
262
- * See {@link #_handleSelectionChangeOnArrowKeyPress}.
263
- */
264
- _preventDefaultOnArrowKeyPress(eventInfo, domEventData) {
265
- const model = this.editor.model;
266
- const schema = model.schema;
267
- const objectElement = model.document.selection.getSelectedElement();
268
- // If object element is selected.
269
- if (objectElement && schema.isObject(objectElement)) {
270
- domEventData.preventDefault();
271
- eventInfo.stop();
272
- }
273
- }
274
- /**
275
- * Handles delete keys: backspace and delete.
276
- *
277
- * @param isForward Set to true if delete was performed in forward direction.
278
- * @returns Returns `true` if keys were handled correctly.
279
- */
280
- _handleDelete(isForward) {
281
- const modelDocument = this.editor.model.document;
282
- const modelSelection = modelDocument.selection;
283
- // Do nothing when the read only mode is enabled.
284
- if (!this.editor.model.canEditAt(modelSelection)) {
285
- return;
286
- }
287
- // Do nothing on non-collapsed selection.
288
- if (!modelSelection.isCollapsed) {
289
- return;
290
- }
291
- const objectElement = this._getObjectElementNextToSelection(isForward);
292
- if (objectElement) {
293
- this.editor.model.change(writer => {
294
- let previousNode = modelSelection.anchor.parent;
295
- // Remove previous element if empty.
296
- while (previousNode.isEmpty) {
297
- const nodeToRemove = previousNode;
298
- previousNode = nodeToRemove.parent;
299
- writer.remove(nodeToRemove);
300
- }
301
- this._setSelectionOverElement(objectElement);
302
- });
303
- return true;
304
- }
305
- }
306
- /**
307
- * Sets {@link module:engine/model/selection~Selection document's selection} over given element.
308
- *
309
- * @internal
310
- */
311
- _setSelectionOverElement(element) {
312
- this.editor.model.change(writer => {
313
- writer.setSelection(writer.createRangeOn(element));
314
- });
315
- }
316
- /**
317
- * Checks if {@link module:engine/model/element~Element element} placed next to the current
318
- * {@link module:engine/model/selection~Selection model selection} exists and is marked in
319
- * {@link module:engine/model/schema~Schema schema} as `object`.
320
- *
321
- * @internal
322
- * @param forward Direction of checking.
323
- */
324
- _getObjectElementNextToSelection(forward) {
325
- const model = this.editor.model;
326
- const schema = model.schema;
327
- const modelSelection = model.document.selection;
328
- // Clone current selection to use it as a probe. We must leave default selection as it is so it can return
329
- // to its current state after undo.
330
- const probe = model.createSelection(modelSelection);
331
- model.modifySelection(probe, { direction: forward ? 'forward' : 'backward' });
332
- // The selection didn't change so there is nothing there.
333
- if (probe.isEqual(modelSelection)) {
334
- return null;
335
- }
336
- const objectElement = forward ? probe.focus.nodeBefore : probe.focus.nodeAfter;
337
- if (!!objectElement && schema.isObject(objectElement)) {
338
- return objectElement;
339
- }
340
- return null;
341
- }
342
- /**
343
- * Removes CSS class from previously selected widgets.
344
- */
345
- _clearPreviouslySelectedWidgets(writer) {
346
- for (const widget of this._previouslySelected) {
347
- writer.removeClass(WIDGET_SELECTED_CLASS_NAME, widget);
348
- }
349
- this._previouslySelected.clear();
350
- }
351
- }
352
- /**
353
- * Returns `true` when element is a nested editable or is placed inside one.
354
- */
355
- function isInsideNestedEditable(element) {
356
- let currentElement = element;
357
- while (currentElement) {
358
- if (currentElement.is('editableElement') && !currentElement.is('rootElement')) {
359
- return true;
360
- }
361
- // Click on nested widget should select it.
362
- if (isWidget(currentElement)) {
363
- return false;
364
- }
365
- currentElement = currentElement.parent;
366
- }
367
- return false;
368
- }
369
- /**
370
- * Checks whether the specified `element` is a child of the `parent` element.
371
- *
372
- * @param element An element to check.
373
- * @param parent A parent for the element.
374
- */
375
- function isChild(element, parent) {
376
- if (!parent) {
377
- return false;
378
- }
379
- return Array.from(element.getAncestors()).includes(parent);
380
- }
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
+ /**
6
+ * @module widget/widget
7
+ */
8
+ import { Plugin } from '@ckeditor/ckeditor5-core';
9
+ import { MouseObserver, TreeWalker } from '@ckeditor/ckeditor5-engine';
10
+ import { Delete } from '@ckeditor/ckeditor5-typing';
11
+ import { env, getLocalizedArrowKeyCodeDirection } from '@ckeditor/ckeditor5-utils';
12
+ import WidgetTypeAround from './widgettypearound/widgettypearound';
13
+ import verticalNavigationHandler from './verticalnavigation';
14
+ import { getLabel, isWidget, WIDGET_SELECTED_CLASS_NAME } from './utils';
15
+ import '../theme/widget.css';
16
+ /**
17
+ * The widget plugin. It enables base support for widgets.
18
+ *
19
+ * See {@glink api/widget package page} for more details and documentation.
20
+ *
21
+ * This plugin enables multiple behaviors required by widgets:
22
+ *
23
+ * * The model to view selection converter for the editing pipeline (it handles widget custom selection rendering).
24
+ * If a converted selection wraps around a widget element, that selection is marked as
25
+ * {@link module:engine/view/selection~Selection#isFake fake}. Additionally, the `ck-widget_selected` CSS class
26
+ * is added to indicate that widget has been selected.
27
+ * * The mouse and keyboard events handling on and around widget elements.
28
+ */
29
+ export default class Widget extends Plugin {
30
+ constructor() {
31
+ super(...arguments);
32
+ /**
33
+ * Holds previously selected widgets.
34
+ */
35
+ this._previouslySelected = new Set();
36
+ }
37
+ /**
38
+ * @inheritDoc
39
+ */
40
+ static get pluginName() {
41
+ return 'Widget';
42
+ }
43
+ /**
44
+ * @inheritDoc
45
+ */
46
+ static get requires() {
47
+ return [WidgetTypeAround, Delete];
48
+ }
49
+ /**
50
+ * @inheritDoc
51
+ */
52
+ init() {
53
+ const editor = this.editor;
54
+ const view = editor.editing.view;
55
+ const viewDocument = view.document;
56
+ // Model to view selection converter.
57
+ // Converts selection placed over widget element to fake selection.
58
+ //
59
+ // By default, the selection is downcasted by the engine to surround the attribute element, even though its only
60
+ // child is an inline widget. A similar thing also happens when a collapsed marker is rendered as a UI element
61
+ // next to an inline widget: the view selection contains both the widget and the marker.
62
+ //
63
+ // This prevents creating a correct fake selection when this inline widget is selected. Normalize the selection
64
+ // in these cases based on the model:
65
+ //
66
+ // [<attributeElement><inlineWidget /></attributeElement>] -> <attributeElement>[<inlineWidget />]</attributeElement>
67
+ // [<uiElement></uiElement><inlineWidget />] -> <uiElement></uiElement>[<inlineWidget />]
68
+ //
69
+ // Thanks to this:
70
+ //
71
+ // * fake selection can be set correctly,
72
+ // * any logic depending on (View)Selection#getSelectedElement() also works OK.
73
+ //
74
+ // See https://github.com/ckeditor/ckeditor5/issues/9524.
75
+ this.editor.editing.downcastDispatcher.on('selection', (evt, data, conversionApi) => {
76
+ const viewWriter = conversionApi.writer;
77
+ const modelSelection = data.selection;
78
+ // The collapsed selection can't contain any widget.
79
+ if (modelSelection.isCollapsed) {
80
+ return;
81
+ }
82
+ const selectedModelElement = modelSelection.getSelectedElement();
83
+ if (!selectedModelElement) {
84
+ return;
85
+ }
86
+ const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
87
+ if (!isWidget(selectedViewElement)) {
88
+ return;
89
+ }
90
+ if (!conversionApi.consumable.consume(modelSelection, 'selection')) {
91
+ return;
92
+ }
93
+ viewWriter.setSelection(viewWriter.createRangeOn(selectedViewElement), {
94
+ fake: true,
95
+ label: getLabel(selectedViewElement)
96
+ });
97
+ });
98
+ // Mark all widgets inside the selection with the css class.
99
+ // This handler is registered at the 'low' priority so it's triggered after the real selection conversion.
100
+ this.editor.editing.downcastDispatcher.on('selection', (evt, data, conversionApi) => {
101
+ // Remove selected class from previously selected widgets.
102
+ this._clearPreviouslySelectedWidgets(conversionApi.writer);
103
+ const viewWriter = conversionApi.writer;
104
+ const viewSelection = viewWriter.document.selection;
105
+ let lastMarked = null;
106
+ for (const range of viewSelection.getRanges()) {
107
+ // Note: There could be multiple selected widgets in a range but no fake selection.
108
+ // All of them must be marked as selected, for instance [<widget></widget><widget></widget>]
109
+ for (const value of range) {
110
+ const node = value.item;
111
+ // Do not mark nested widgets in selected one. See: #4594
112
+ if (isWidget(node) && !isChild(node, lastMarked)) {
113
+ viewWriter.addClass(WIDGET_SELECTED_CLASS_NAME, node);
114
+ this._previouslySelected.add(node);
115
+ lastMarked = node;
116
+ }
117
+ }
118
+ }
119
+ }, { priority: 'low' });
120
+ // If mouse down is pressed on widget - create selection over whole widget.
121
+ view.addObserver(MouseObserver);
122
+ this.listenTo(viewDocument, 'mousedown', (...args) => this._onMousedown(...args));
123
+ // There are two keydown listeners working on different priorities. This allows other
124
+ // features such as WidgetTypeAround or TableKeyboard to attach their listeners in between
125
+ // and customize the behavior even further in different content/selection scenarios.
126
+ //
127
+ // * The first listener handles changing the selection on arrow key press
128
+ // if the widget is selected or if the selection is next to a widget and the widget
129
+ // should become selected upon the arrow key press.
130
+ //
131
+ // * The second (late) listener makes sure the default browser action on arrow key press is
132
+ // prevented when a widget is selected. This prevents the selection from being moved
133
+ // from a fake selection container.
134
+ this.listenTo(viewDocument, 'arrowKey', (...args) => {
135
+ this._handleSelectionChangeOnArrowKeyPress(...args);
136
+ }, { context: [isWidget, '$text'] });
137
+ this.listenTo(viewDocument, 'arrowKey', (...args) => {
138
+ this._preventDefaultOnArrowKeyPress(...args);
139
+ }, { context: '$root' });
140
+ this.listenTo(viewDocument, 'arrowKey', verticalNavigationHandler(this.editor.editing), { context: '$text' });
141
+ // Handle custom delete behaviour.
142
+ this.listenTo(viewDocument, 'delete', (evt, data) => {
143
+ if (this._handleDelete(data.direction == 'forward')) {
144
+ data.preventDefault();
145
+ evt.stop();
146
+ }
147
+ }, { context: '$root' });
148
+ }
149
+ /**
150
+ * Handles {@link module:engine/view/document~Document#event:mousedown mousedown} events on widget elements.
151
+ */
152
+ _onMousedown(eventInfo, domEventData) {
153
+ const editor = this.editor;
154
+ const view = editor.editing.view;
155
+ const viewDocument = view.document;
156
+ let element = domEventData.target;
157
+ // If triple click should select entire paragraph.
158
+ if (domEventData.domEvent.detail >= 3) {
159
+ if (this._selectBlockContent(element)) {
160
+ domEventData.preventDefault();
161
+ }
162
+ return;
163
+ }
164
+ // Do nothing for single or double click inside nested editable.
165
+ if (isInsideNestedEditable(element)) {
166
+ return;
167
+ }
168
+ // If target is not a widget element - check if one of the ancestors is.
169
+ if (!isWidget(element)) {
170
+ element = element.findAncestor(isWidget);
171
+ if (!element) {
172
+ return;
173
+ }
174
+ }
175
+ // On Android selection would jump to the first table cell, on other devices
176
+ // we can't block it (and don't need to) because of drag and drop support.
177
+ if (env.isAndroid) {
178
+ domEventData.preventDefault();
179
+ }
180
+ // Focus editor if is not focused already.
181
+ if (!viewDocument.isFocused) {
182
+ view.focus();
183
+ }
184
+ // Create model selection over widget.
185
+ const modelElement = editor.editing.mapper.toModelElement(element);
186
+ this._setSelectionOverElement(modelElement);
187
+ }
188
+ /**
189
+ * Selects entire block content, e.g. on triple click it selects entire paragraph.
190
+ */
191
+ _selectBlockContent(element) {
192
+ const editor = this.editor;
193
+ const model = editor.model;
194
+ const mapper = editor.editing.mapper;
195
+ const schema = model.schema;
196
+ const viewElement = mapper.findMappedViewAncestor(this.editor.editing.view.createPositionAt(element, 0));
197
+ const modelElement = findTextBlockAncestor(mapper.toModelElement(viewElement), model.schema);
198
+ if (!modelElement) {
199
+ return false;
200
+ }
201
+ model.change(writer => {
202
+ const nextTextBlock = !schema.isLimit(modelElement) ?
203
+ findNextTextBlock(writer.createPositionAfter(modelElement), schema) :
204
+ null;
205
+ const start = writer.createPositionAt(modelElement, 0);
206
+ const end = nextTextBlock ?
207
+ writer.createPositionAt(nextTextBlock, 0) :
208
+ writer.createPositionAt(modelElement, 'end');
209
+ writer.setSelection(writer.createRange(start, end));
210
+ });
211
+ return true;
212
+ }
213
+ /**
214
+ * Handles {@link module:engine/view/document~Document#event:keydown keydown} events and changes
215
+ * the model selection when:
216
+ *
217
+ * * arrow key is pressed when the widget is selected,
218
+ * * the selection is next to a widget and the widget should become selected upon the arrow key press.
219
+ *
220
+ * See {@link #_preventDefaultOnArrowKeyPress}.
221
+ */
222
+ _handleSelectionChangeOnArrowKeyPress(eventInfo, domEventData) {
223
+ const keyCode = domEventData.keyCode;
224
+ const model = this.editor.model;
225
+ const schema = model.schema;
226
+ const modelSelection = model.document.selection;
227
+ const objectElement = modelSelection.getSelectedElement();
228
+ const direction = getLocalizedArrowKeyCodeDirection(keyCode, this.editor.locale.contentLanguageDirection);
229
+ const isForward = direction == 'down' || direction == 'right';
230
+ const isVerticalNavigation = direction == 'up' || direction == 'down';
231
+ // If object element is selected.
232
+ if (objectElement && schema.isObject(objectElement)) {
233
+ const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition();
234
+ const newRange = schema.getNearestSelectionRange(position, isForward ? 'forward' : 'backward');
235
+ if (newRange) {
236
+ model.change(writer => {
237
+ writer.setSelection(newRange);
238
+ });
239
+ domEventData.preventDefault();
240
+ eventInfo.stop();
241
+ }
242
+ return;
243
+ }
244
+ // Handle collapsing of the selection when there is any widget on the edge of selection.
245
+ // This is needed because browsers have problems with collapsing such selection.
246
+ if (!modelSelection.isCollapsed && !domEventData.shiftKey) {
247
+ const firstPosition = modelSelection.getFirstPosition();
248
+ const lastPosition = modelSelection.getLastPosition();
249
+ const firstSelectedNode = firstPosition.nodeAfter;
250
+ const lastSelectedNode = lastPosition.nodeBefore;
251
+ if (firstSelectedNode && schema.isObject(firstSelectedNode) || lastSelectedNode && schema.isObject(lastSelectedNode)) {
252
+ model.change(writer => {
253
+ writer.setSelection(isForward ? lastPosition : firstPosition);
254
+ });
255
+ domEventData.preventDefault();
256
+ eventInfo.stop();
257
+ }
258
+ return;
259
+ }
260
+ // Return if not collapsed.
261
+ if (!modelSelection.isCollapsed) {
262
+ return;
263
+ }
264
+ // If selection is next to object element.
265
+ const objectElementNextToSelection = this._getObjectElementNextToSelection(isForward);
266
+ if (objectElementNextToSelection && schema.isObject(objectElementNextToSelection)) {
267
+ // Do not select an inline widget while handling up/down arrow.
268
+ if (schema.isInline(objectElementNextToSelection) && isVerticalNavigation) {
269
+ return;
270
+ }
271
+ this._setSelectionOverElement(objectElementNextToSelection);
272
+ domEventData.preventDefault();
273
+ eventInfo.stop();
274
+ }
275
+ }
276
+ /**
277
+ * Handles {@link module:engine/view/document~Document#event:keydown keydown} events and prevents
278
+ * the default browser behavior to make sure the fake selection is not being moved from a fake selection
279
+ * container.
280
+ *
281
+ * See {@link #_handleSelectionChangeOnArrowKeyPress}.
282
+ */
283
+ _preventDefaultOnArrowKeyPress(eventInfo, domEventData) {
284
+ const model = this.editor.model;
285
+ const schema = model.schema;
286
+ const objectElement = model.document.selection.getSelectedElement();
287
+ // If object element is selected.
288
+ if (objectElement && schema.isObject(objectElement)) {
289
+ domEventData.preventDefault();
290
+ eventInfo.stop();
291
+ }
292
+ }
293
+ /**
294
+ * Handles delete keys: backspace and delete.
295
+ *
296
+ * @param isForward Set to true if delete was performed in forward direction.
297
+ * @returns Returns `true` if keys were handled correctly.
298
+ */
299
+ _handleDelete(isForward) {
300
+ const modelDocument = this.editor.model.document;
301
+ const modelSelection = modelDocument.selection;
302
+ // Do nothing when the read only mode is enabled.
303
+ if (!this.editor.model.canEditAt(modelSelection)) {
304
+ return;
305
+ }
306
+ // Do nothing on non-collapsed selection.
307
+ if (!modelSelection.isCollapsed) {
308
+ return;
309
+ }
310
+ const objectElement = this._getObjectElementNextToSelection(isForward);
311
+ if (objectElement) {
312
+ this.editor.model.change(writer => {
313
+ let previousNode = modelSelection.anchor.parent;
314
+ // Remove previous element if empty.
315
+ while (previousNode.isEmpty) {
316
+ const nodeToRemove = previousNode;
317
+ previousNode = nodeToRemove.parent;
318
+ writer.remove(nodeToRemove);
319
+ }
320
+ this._setSelectionOverElement(objectElement);
321
+ });
322
+ return true;
323
+ }
324
+ }
325
+ /**
326
+ * Sets {@link module:engine/model/selection~Selection document's selection} over given element.
327
+ *
328
+ * @internal
329
+ */
330
+ _setSelectionOverElement(element) {
331
+ this.editor.model.change(writer => {
332
+ writer.setSelection(writer.createRangeOn(element));
333
+ });
334
+ }
335
+ /**
336
+ * Checks if {@link module:engine/model/element~Element element} placed next to the current
337
+ * {@link module:engine/model/selection~Selection model selection} exists and is marked in
338
+ * {@link module:engine/model/schema~Schema schema} as `object`.
339
+ *
340
+ * @internal
341
+ * @param forward Direction of checking.
342
+ */
343
+ _getObjectElementNextToSelection(forward) {
344
+ const model = this.editor.model;
345
+ const schema = model.schema;
346
+ const modelSelection = model.document.selection;
347
+ // Clone current selection to use it as a probe. We must leave default selection as it is so it can return
348
+ // to its current state after undo.
349
+ const probe = model.createSelection(modelSelection);
350
+ model.modifySelection(probe, { direction: forward ? 'forward' : 'backward' });
351
+ // The selection didn't change so there is nothing there.
352
+ if (probe.isEqual(modelSelection)) {
353
+ return null;
354
+ }
355
+ const objectElement = forward ? probe.focus.nodeBefore : probe.focus.nodeAfter;
356
+ if (!!objectElement && schema.isObject(objectElement)) {
357
+ return objectElement;
358
+ }
359
+ return null;
360
+ }
361
+ /**
362
+ * Removes CSS class from previously selected widgets.
363
+ */
364
+ _clearPreviouslySelectedWidgets(writer) {
365
+ for (const widget of this._previouslySelected) {
366
+ writer.removeClass(WIDGET_SELECTED_CLASS_NAME, widget);
367
+ }
368
+ this._previouslySelected.clear();
369
+ }
370
+ }
371
+ /**
372
+ * Returns `true` when element is a nested editable or is placed inside one.
373
+ */
374
+ function isInsideNestedEditable(element) {
375
+ let currentElement = element;
376
+ while (currentElement) {
377
+ if (currentElement.is('editableElement') && !currentElement.is('rootElement')) {
378
+ return true;
379
+ }
380
+ // Click on nested widget should select it.
381
+ if (isWidget(currentElement)) {
382
+ return false;
383
+ }
384
+ currentElement = currentElement.parent;
385
+ }
386
+ return false;
387
+ }
388
+ /**
389
+ * Checks whether the specified `element` is a child of the `parent` element.
390
+ *
391
+ * @param element An element to check.
392
+ * @param parent A parent for the element.
393
+ */
394
+ function isChild(element, parent) {
395
+ if (!parent) {
396
+ return false;
397
+ }
398
+ return Array.from(element.getAncestors()).includes(parent);
399
+ }
400
+ /**
401
+ * Returns nearest text block ancestor.
402
+ */
403
+ function findTextBlockAncestor(modelElement, schema) {
404
+ for (const element of modelElement.getAncestors({ includeSelf: true, parentFirst: true })) {
405
+ if (schema.checkChild(element, '$text')) {
406
+ return element;
407
+ }
408
+ // Do not go beyond nested editable.
409
+ if (schema.isLimit(element) && !schema.isObject(element)) {
410
+ break;
411
+ }
412
+ }
413
+ return null;
414
+ }
415
+ /**
416
+ * Returns next text block where could put selection.
417
+ */
418
+ function findNextTextBlock(position, schema) {
419
+ const treeWalker = new TreeWalker({ startPosition: position });
420
+ for (const { item } of treeWalker) {
421
+ if (schema.isLimit(item) || !item.is('element')) {
422
+ return null;
423
+ }
424
+ if (schema.checkChild(item, '$text')) {
425
+ return item;
426
+ }
427
+ }
428
+ return null;
429
+ }