@ckeditor/ckeditor5-widget 47.6.1 → 48.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/LICENSE.md +1 -1
  2. package/dist/index-editor.css +484 -122
  3. package/dist/index.css +479 -187
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/index.js.map +1 -1
  7. package/package.json +26 -46
  8. package/lang/contexts.json +0 -12
  9. package/lang/translations/af.po +0 -52
  10. package/lang/translations/ar.po +0 -52
  11. package/lang/translations/ast.po +0 -52
  12. package/lang/translations/az.po +0 -52
  13. package/lang/translations/be.po +0 -52
  14. package/lang/translations/bg.po +0 -52
  15. package/lang/translations/bn.po +0 -52
  16. package/lang/translations/bs.po +0 -52
  17. package/lang/translations/ca.po +0 -52
  18. package/lang/translations/cs.po +0 -52
  19. package/lang/translations/da.po +0 -52
  20. package/lang/translations/de-ch.po +0 -52
  21. package/lang/translations/de.po +0 -52
  22. package/lang/translations/el.po +0 -52
  23. package/lang/translations/en-au.po +0 -52
  24. package/lang/translations/en-gb.po +0 -52
  25. package/lang/translations/en.po +0 -52
  26. package/lang/translations/eo.po +0 -52
  27. package/lang/translations/es-co.po +0 -52
  28. package/lang/translations/es.po +0 -52
  29. package/lang/translations/et.po +0 -52
  30. package/lang/translations/eu.po +0 -52
  31. package/lang/translations/fa.po +0 -52
  32. package/lang/translations/fi.po +0 -52
  33. package/lang/translations/fr.po +0 -52
  34. package/lang/translations/gl.po +0 -52
  35. package/lang/translations/gu.po +0 -52
  36. package/lang/translations/he.po +0 -52
  37. package/lang/translations/hi.po +0 -52
  38. package/lang/translations/hr.po +0 -52
  39. package/lang/translations/hu.po +0 -52
  40. package/lang/translations/hy.po +0 -52
  41. package/lang/translations/id.po +0 -52
  42. package/lang/translations/it.po +0 -52
  43. package/lang/translations/ja.po +0 -52
  44. package/lang/translations/jv.po +0 -52
  45. package/lang/translations/kk.po +0 -52
  46. package/lang/translations/km.po +0 -52
  47. package/lang/translations/kn.po +0 -52
  48. package/lang/translations/ko.po +0 -52
  49. package/lang/translations/ku.po +0 -52
  50. package/lang/translations/lt.po +0 -52
  51. package/lang/translations/lv.po +0 -52
  52. package/lang/translations/ms.po +0 -52
  53. package/lang/translations/nb.po +0 -52
  54. package/lang/translations/ne.po +0 -52
  55. package/lang/translations/nl.po +0 -52
  56. package/lang/translations/no.po +0 -52
  57. package/lang/translations/oc.po +0 -52
  58. package/lang/translations/pl.po +0 -52
  59. package/lang/translations/pt-br.po +0 -52
  60. package/lang/translations/pt.po +0 -52
  61. package/lang/translations/ro.po +0 -52
  62. package/lang/translations/ru.po +0 -52
  63. package/lang/translations/si.po +0 -52
  64. package/lang/translations/sk.po +0 -52
  65. package/lang/translations/sl.po +0 -52
  66. package/lang/translations/sq.po +0 -52
  67. package/lang/translations/sr-latn.po +0 -52
  68. package/lang/translations/sr.po +0 -52
  69. package/lang/translations/sv.po +0 -52
  70. package/lang/translations/th.po +0 -52
  71. package/lang/translations/ti.po +0 -52
  72. package/lang/translations/tk.po +0 -52
  73. package/lang/translations/tr.po +0 -52
  74. package/lang/translations/tt.po +0 -52
  75. package/lang/translations/ug.po +0 -52
  76. package/lang/translations/uk.po +0 -52
  77. package/lang/translations/ur.po +0 -52
  78. package/lang/translations/uz.po +0 -52
  79. package/lang/translations/vi.po +0 -52
  80. package/lang/translations/zh-cn.po +0 -52
  81. package/lang/translations/zh.po +0 -52
  82. package/src/augmentation.js +0 -5
  83. package/src/highlightstack.js +0 -126
  84. package/src/index.js +0 -19
  85. package/src/utils.js +0 -414
  86. package/src/verticalnavigation.js +0 -185
  87. package/src/widget.js +0 -727
  88. package/src/widgetresize/resizer.js +0 -390
  89. package/src/widgetresize/resizerstate.js +0 -165
  90. package/src/widgetresize/sizeview.js +0 -65
  91. package/src/widgetresize.js +0 -193
  92. package/src/widgettoolbarrepository.js +0 -274
  93. package/src/widgettypearound/utils.js +0 -60
  94. package/src/widgettypearound/widgettypearound.js +0 -778
  95. package/theme/widget.css +0 -91
  96. package/theme/widgetresize.css +0 -43
  97. package/theme/widgettypearound.css +0 -119
  98. /package/{src → dist}/augmentation.d.ts +0 -0
  99. /package/{src → dist}/highlightstack.d.ts +0 -0
  100. /package/{src → dist}/index.d.ts +0 -0
  101. /package/{src → dist}/utils.d.ts +0 -0
  102. /package/{src → dist}/verticalnavigation.d.ts +0 -0
  103. /package/{src → dist}/widget.d.ts +0 -0
  104. /package/{src → dist}/widgetresize/resizer.d.ts +0 -0
  105. /package/{src → dist}/widgetresize/resizerstate.d.ts +0 -0
  106. /package/{src → dist}/widgetresize/sizeview.d.ts +0 -0
  107. /package/{src → dist}/widgetresize.d.ts +0 -0
  108. /package/{src → dist}/widgettoolbarrepository.d.ts +0 -0
  109. /package/{src → dist}/widgettypearound/utils.d.ts +0 -0
  110. /package/{src → dist}/widgettypearound/widgettypearound.d.ts +0 -0
package/src/utils.js DELETED
@@ -1,414 +0,0 @@
1
- /**
2
- * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
3
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
4
- */
5
- /**
6
- * @module widget/utils
7
- */
8
- import { IconDragHandle } from '@ckeditor/ckeditor5-icons';
9
- import { Rect, CKEditorError, toArray } from '@ckeditor/ckeditor5-utils';
10
- import { IconView } from '@ckeditor/ckeditor5-ui';
11
- import { WidgetHighlightStack } from './highlightstack.js';
12
- import { getTypeAroundFakeCaretPosition } from './widgettypearound/utils.js';
13
- /**
14
- * CSS class added to each widget element.
15
- */
16
- export const WIDGET_CLASS_NAME = 'ck-widget';
17
- /**
18
- * CSS class added to currently selected widget element.
19
- */
20
- export const WIDGET_SELECTED_CLASS_NAME = 'ck-widget_selected';
21
- /**
22
- * Returns `true` if given {@link module:engine/view/node~ViewNode} is an {@link module:engine/view/element~ViewElement} and a widget.
23
- */
24
- export function isWidget(node) {
25
- if (!node.is('element')) {
26
- return false;
27
- }
28
- return !!node.getCustomProperty('widget');
29
- }
30
- /**
31
- * Converts the given {@link module:engine/view/element~ViewElement} to a widget in the following way:
32
- *
33
- * * sets the `contenteditable` attribute to `"false"`,
34
- * * adds the `ck-widget` CSS class,
35
- * * adds a custom {@link module:engine/view/element~ViewElement#getFillerOffset `getFillerOffset()`} method returning `null`,
36
- * * adds a custom property allowing to recognize widget elements by using {@link ~isWidget `isWidget()`},
37
- * * implements the {@link ~setHighlightHandling view highlight on widgets}.
38
- *
39
- * This function needs to be used in conjunction with
40
- * {@link module:engine/conversion/downcasthelpers~DowncastHelpers downcast conversion helpers}
41
- * like {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`}.
42
- * Moreover, typically you will want to use `toWidget()` only for `editingDowncast`, while keeping the `dataDowncast` clean.
43
- *
44
- * For example, in order to convert a `<widget>` model element to `<div class="widget">` in the view, you can define
45
- * such converters:
46
- *
47
- * ```ts
48
- * editor.conversion.for( 'editingDowncast' )
49
- * .elementToElement( {
50
- * model: 'widget',
51
- * view: ( modelItem, { writer } ) => {
52
- * const div = writer.createContainerElement( 'div', { class: 'widget' } );
53
- *
54
- * return toWidget( div, writer, { label: 'some widget' } );
55
- * }
56
- * } );
57
- *
58
- * editor.conversion.for( 'dataDowncast' )
59
- * .elementToElement( {
60
- * model: 'widget',
61
- * view: ( modelItem, { writer } ) => {
62
- * return writer.createContainerElement( 'div', { class: 'widget' } );
63
- * }
64
- * } );
65
- * ```
66
- *
67
- * See the full source code of the widget (with a nested editable) schema definition and converters in
68
- * [this sample](https://github.com/ckeditor/ckeditor5-widget/blob/master/tests/manual/widget-with-nestededitable.js).
69
- *
70
- * @param options Additional options.
71
- * @param options.label Element's label provided to the {@link ~setLabel} function. It can be passed as
72
- * a plain string or a function returning a string. It represents the widget for assistive technologies (like screen readers).
73
- * @param options.hasSelectionHandle If `true`, the widget will have a selection handle added.
74
- * @returns Returns the same element.
75
- */
76
- export function toWidget(element, writer, options = {}) {
77
- if (!element.is('containerElement')) {
78
- /**
79
- * The element passed to `toWidget()` must be a {@link module:engine/view/containerelement~ViewContainerElement}
80
- * instance.
81
- *
82
- * @error widget-to-widget-wrong-element-type
83
- * @param {any} element The view element passed to `toWidget()`.
84
- */
85
- throw new CKEditorError('widget-to-widget-wrong-element-type', null, { element });
86
- }
87
- writer.setAttribute('contenteditable', 'false', element);
88
- writer.addClass(WIDGET_CLASS_NAME, element);
89
- writer.setCustomProperty('widget', true, element);
90
- element.getFillerOffset = getFillerOffset;
91
- writer.setCustomProperty('widgetLabel', [], element);
92
- if (options.label) {
93
- setLabel(element, options.label);
94
- }
95
- if (options.hasSelectionHandle) {
96
- addSelectionHandle(element, writer);
97
- }
98
- setHighlightHandling(element, writer);
99
- return element;
100
- }
101
- /**
102
- * Default handler for adding a highlight on a widget.
103
- * It adds CSS class and attributes basing on the given highlight descriptor.
104
- */
105
- function addHighlight(element, descriptor, writer) {
106
- if (descriptor.classes) {
107
- writer.addClass(toArray(descriptor.classes), element);
108
- }
109
- if (descriptor.attributes) {
110
- for (const key in descriptor.attributes) {
111
- writer.setAttribute(key, descriptor.attributes[key], element);
112
- }
113
- }
114
- }
115
- /**
116
- * Default handler for removing a highlight from a widget.
117
- * It removes CSS class and attributes basing on the given highlight descriptor.
118
- */
119
- function removeHighlight(element, descriptor, writer) {
120
- if (descriptor.classes) {
121
- writer.removeClass(toArray(descriptor.classes), element);
122
- }
123
- if (descriptor.attributes) {
124
- for (const key in descriptor.attributes) {
125
- writer.removeAttribute(key, element);
126
- }
127
- }
128
- }
129
- /**
130
- * Sets highlight handling methods. Uses {@link module:widget/highlightstack~WidgetHighlightStack} to
131
- * properly determine which highlight descriptor should be used at given time.
132
- */
133
- export function setHighlightHandling(element, writer, add = addHighlight, remove = removeHighlight) {
134
- const stack = new WidgetHighlightStack();
135
- stack.on('change:top', (evt, data) => {
136
- if (data.oldDescriptor) {
137
- remove(element, data.oldDescriptor, data.writer);
138
- }
139
- if (data.newDescriptor) {
140
- add(element, data.newDescriptor, data.writer);
141
- }
142
- });
143
- const addHighlightCallback = (element, descriptor, writer) => stack.add(descriptor, writer);
144
- const removeHighlightCallback = (element, id, writer) => stack.remove(id, writer);
145
- writer.setCustomProperty('addHighlight', addHighlightCallback, element);
146
- writer.setCustomProperty('removeHighlight', removeHighlightCallback, element);
147
- }
148
- /**
149
- * Sets label for given element.
150
- * It can be passed as a plain string or a function returning a string. Function will be called each time label is retrieved by
151
- * {@link ~getLabel `getLabel()`}.
152
- */
153
- export function setLabel(element, labelOrCreator) {
154
- const widgetLabel = element.getCustomProperty('widgetLabel');
155
- widgetLabel.push(labelOrCreator);
156
- }
157
- /**
158
- * Returns the label of the provided element.
159
- */
160
- export function getLabel(element) {
161
- const widgetLabel = element.getCustomProperty('widgetLabel');
162
- return widgetLabel.reduce((prev, current) => {
163
- if (typeof current === 'function') {
164
- return prev ? prev + '. ' + current() : current();
165
- }
166
- else {
167
- return prev ? prev + '. ' + current : current;
168
- }
169
- }, '');
170
- }
171
- /**
172
- * Adds functionality to the provided {@link module:engine/view/editableelement~ViewEditableElement} to act as a widget's editable:
173
- *
174
- * * sets the `contenteditable` attribute to `true` when
175
- * {@link module:engine/view/editableelement~ViewEditableElement#isReadOnly} is `false`,
176
- * otherwise sets it to `false`,
177
- * * adds the `ck-editor__editable` and `ck-editor__nested-editable` CSS classes,
178
- * * adds the `ck-editor__nested-editable_focused` CSS class when the editable is focused and removes it when it is blurred.
179
- * * implements the {@link ~setHighlightHandling view highlight on widget's editable}.
180
- * * sets the `role` attribute to `textbox` for accessibility purposes.
181
- *
182
- * Similarly to {@link ~toWidget `toWidget()`} this function should be used in `editingDowncast` only and it is usually
183
- * used together with {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`}.
184
- *
185
- * For example, in order to convert a `<nested>` model element to `<div class="nested">` in the view, you can define
186
- * such converters:
187
- *
188
- * ```ts
189
- * editor.conversion.for( 'editingDowncast' )
190
- * .elementToElement( {
191
- * model: 'nested',
192
- * view: ( modelItem, { writer } ) => {
193
- * const div = writer.createEditableElement( 'div', { class: 'nested' } );
194
- *
195
- * return toWidgetEditable( nested, writer, { label: 'label for editable' } );
196
- * }
197
- * } );
198
- *
199
- * editor.conversion.for( 'dataDowncast' )
200
- * .elementToElement( {
201
- * model: 'nested',
202
- * view: ( modelItem, { writer } ) => {
203
- * return writer.createContainerElement( 'div', { class: 'nested' } );
204
- * }
205
- * } );
206
- * ```
207
- *
208
- * See the full source code of the widget (with nested editable) schema definition and converters in
209
- * [this sample](https://github.com/ckeditor/ckeditor5-widget/blob/master/tests/manual/widget-with-nestededitable.js).
210
- *
211
- * @param options Additional options.
212
- * @param options.label Editable's label used by assistive technologies (e.g. screen readers).
213
- * @param options.withAriaRole Whether to add the role="textbox" attribute on the editable. Defaults to `true`.
214
- * @returns Returns the same element that was provided in the `editable` parameter
215
- */
216
- export function toWidgetEditable(editable, writer, options = {}) {
217
- writer.addClass(['ck-editor__editable', 'ck-editor__nested-editable'], editable);
218
- // Set role="textbox" only if explicitly requested (defaults to true for backward compatibility).
219
- if (options.withAriaRole !== false) {
220
- writer.setAttribute('role', 'textbox', editable);
221
- }
222
- // Setting tabindex=-1 on contenteditable=false makes it focusable. It propagates focus to the editable
223
- // element and makes it possible to highlight nested editables as focused. It's not what we want
224
- // for read-only editables though.
225
- // See more: https://github.com/ckeditor/ckeditor5/issues/18965
226
- if (!editable.isReadOnly) {
227
- writer.setAttribute('tabindex', '-1', editable);
228
- }
229
- if (options.label) {
230
- writer.setAttribute('aria-label', options.label, editable);
231
- }
232
- // Set initial contenteditable value.
233
- writer.setAttribute('contenteditable', editable.isReadOnly ? 'false' : 'true', editable);
234
- // Bind the contenteditable property to element#isReadOnly.
235
- editable.on('change:isReadOnly', (evt, property, isReadonly) => {
236
- writer.setAttribute('contenteditable', isReadonly ? 'false' : 'true', editable);
237
- if (isReadonly) {
238
- writer.removeAttribute('tabindex', editable);
239
- }
240
- else {
241
- writer.setAttribute('tabindex', '-1', editable);
242
- }
243
- });
244
- editable.on('change:isFocused', (evt, property, is) => {
245
- if (is) {
246
- writer.addClass('ck-editor__nested-editable_focused', editable);
247
- }
248
- else {
249
- writer.removeClass('ck-editor__nested-editable_focused', editable);
250
- }
251
- });
252
- setHighlightHandling(editable, writer);
253
- return editable;
254
- }
255
- /**
256
- * Returns a model range which is optimal (in terms of UX) for inserting a widget block.
257
- *
258
- * For instance, if a selection is in the middle of a paragraph, the collapsed range before this paragraph
259
- * will be returned so that it is not split. If the selection is at the end of a paragraph,
260
- * the collapsed range after this paragraph will be returned.
261
- *
262
- * Note: If the selection is placed in an empty block, the range in that block will be returned. If that range
263
- * is then passed to {@link module:engine/model/model~Model#insertContent}, the block will be fully replaced
264
- * by the inserted widget block.
265
- *
266
- * @param selection The selection based on which the insertion position should be calculated.
267
- * @param model Model instance.
268
- * @returns The optimal range.
269
- */
270
- export function findOptimalInsertionRange(selection, model) {
271
- const selectedElement = selection.getSelectedElement();
272
- if (selectedElement) {
273
- const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(selection);
274
- // If the WidgetTypeAround "fake caret" is displayed, use its position for the insertion
275
- // to provide the most predictable UX (https://github.com/ckeditor/ckeditor5/issues/7438).
276
- if (typeAroundFakeCaretPosition) {
277
- return model.createRange(model.createPositionAt(selectedElement, typeAroundFakeCaretPosition));
278
- }
279
- }
280
- return model.schema.findOptimalInsertionRange(selection);
281
- }
282
- /**
283
- * A util to be used in order to map view positions to correct model positions when implementing a widget
284
- * which renders non-empty view element for an empty model element.
285
- *
286
- * For example:
287
- *
288
- * ```
289
- * // Model:
290
- * <placeholder type="name"></placeholder>
291
- *
292
- * // View:
293
- * <span class="placeholder">name</span>
294
- * ```
295
- *
296
- * In such case, view positions inside `<span>` cannot be correctly mapped to the model (because the model element is empty).
297
- * To handle mapping positions inside `<span class="placeholder">` to the model use this util as follows:
298
- *
299
- * ```ts
300
- * editor.editing.mapper.on(
301
- * 'viewToModelPosition',
302
- * viewToModelPositionOutsideModelElement( model, viewElement => viewElement.hasClass( 'placeholder' ) )
303
- * );
304
- * ```
305
- *
306
- * The callback will try to map the view offset of selection to an expected model position.
307
- *
308
- * 1. When the position is at the end (or in the middle) of the inline widget:
309
- *
310
- * ```
311
- * // View:
312
- * <p>foo <span class="placeholder">name|</span> bar</p>
313
- *
314
- * // Model:
315
- * <paragraph>foo <placeholder type="name"></placeholder>| bar</paragraph>
316
- * ```
317
- *
318
- * 2. When the position is at the beginning of the inline widget:
319
- *
320
- * ```
321
- * // View:
322
- * <p>foo <span class="placeholder">|name</span> bar</p>
323
- *
324
- * // Model:
325
- * <paragraph>foo |<placeholder type="name"></placeholder> bar</paragraph>
326
- * ```
327
- *
328
- * @param model Model instance on which the callback operates.
329
- * @param viewElementMatcher Function that is passed a view element and should return `true` if the custom mapping
330
- * should be applied to the given view element.
331
- */
332
- export function viewToModelPositionOutsideModelElement(model, viewElementMatcher) {
333
- return (evt, data) => {
334
- const { mapper, viewPosition } = data;
335
- const viewParent = mapper.findMappedViewAncestor(viewPosition);
336
- if (!viewElementMatcher(viewParent)) {
337
- return;
338
- }
339
- const modelParent = mapper.toModelElement(viewParent);
340
- data.modelPosition = model.createPositionAt(modelParent, viewPosition.isAtStart ? 'before' : 'after');
341
- };
342
- }
343
- /**
344
- * Default filler offset function applied to all widget elements.
345
- */
346
- function getFillerOffset() {
347
- return null;
348
- }
349
- /**
350
- * Adds a drag handle to the widget.
351
- */
352
- function addSelectionHandle(widgetElement, writer) {
353
- const selectionHandle = writer.createUIElement('div', { class: 'ck ck-widget__selection-handle' }, function (domDocument) {
354
- const domElement = this.toDomElement(domDocument);
355
- // Use the IconView from the ui library.
356
- const icon = new IconView();
357
- icon.set('content', IconDragHandle);
358
- // Render the icon view right away to append its #element to the selectionHandle DOM element.
359
- icon.render();
360
- domElement.appendChild(icon.element);
361
- return domElement;
362
- });
363
- // Append the selection handle into the widget wrapper.
364
- writer.insert(writer.createPositionAt(widgetElement, 0), selectionHandle);
365
- writer.addClass(['ck-widget_with-selection-handle'], widgetElement);
366
- }
367
- /**
368
- * Starting from a DOM resize host element (an element that receives dimensions as a result of resizing),
369
- * this helper returns the width of the found ancestor element.
370
- *
371
- * * It searches up to 5 levels of ancestors only.
372
- *
373
- * @param domResizeHost Resize host DOM element that receives dimensions as a result of resizing.
374
- * @returns Width of ancestor element in pixels or 0 if no ancestor with a computed width has been found.
375
- */
376
- export function calculateResizeHostAncestorWidth(domResizeHost) {
377
- const getElementComputedWidth = (element) => {
378
- const { width, paddingLeft, paddingRight } = element.ownerDocument.defaultView.getComputedStyle(element);
379
- return parseFloat(width) - (parseFloat(paddingLeft) || 0) - (parseFloat(paddingRight) || 0);
380
- };
381
- const domResizeHostParent = domResizeHost.parentElement;
382
- if (!domResizeHostParent) {
383
- return 0;
384
- }
385
- // Need to use computed style as it properly excludes parent's paddings from the returned value.
386
- let parentWidth = getElementComputedWidth(domResizeHostParent);
387
- // Sometimes parent width cannot be accessed. If that happens we should go up in the elements tree
388
- // and try to get width from next ancestor.
389
- // https://github.com/ckeditor/ckeditor5/issues/10776
390
- const ancestorLevelLimit = 5;
391
- let currentLevel = 0;
392
- let checkedElement = domResizeHostParent;
393
- while (isNaN(parentWidth)) {
394
- checkedElement = checkedElement.parentElement;
395
- if (++currentLevel > ancestorLevelLimit) {
396
- return 0;
397
- }
398
- parentWidth = getElementComputedWidth(checkedElement);
399
- }
400
- return parentWidth;
401
- }
402
- /**
403
- * Calculates a relative width of a `domResizeHost` compared to its ancestor in percents.
404
- *
405
- * @param domResizeHost Resize host DOM element.
406
- * @returns Percentage value between 0 and 100.
407
- */
408
- export function calculateResizeHostPercentageWidth(domResizeHost, resizeHostRect = new Rect(domResizeHost)) {
409
- const parentWidth = calculateResizeHostAncestorWidth(domResizeHost);
410
- if (!parentWidth) {
411
- return 0;
412
- }
413
- return resizeHostRect.width / parentWidth * 100;
414
- }
@@ -1,185 +0,0 @@
1
- /**
2
- * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
3
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
4
- */
5
- /**
6
- * @module widget/verticalnavigation
7
- */
8
- import { keyCodes, Rect } from '@ckeditor/ckeditor5-utils';
9
- /**
10
- * Returns 'keydown' handler for up/down arrow keys that modifies the caret movement if it's in a text line next to an object.
11
- *
12
- * @param editing The editing controller.
13
- */
14
- export function verticalWidgetNavigationHandler(editing) {
15
- const model = editing.model;
16
- return (evt, data) => {
17
- const arrowUpPressed = data.keyCode == keyCodes.arrowup;
18
- const arrowDownPressed = data.keyCode == keyCodes.arrowdown;
19
- const expandSelection = data.shiftKey;
20
- const selection = model.document.selection;
21
- if (!arrowUpPressed && !arrowDownPressed) {
22
- return;
23
- }
24
- const isForward = arrowDownPressed;
25
- // Find a range between selection and closest limit element.
26
- const range = findTextRangeFromSelection(editing, selection, isForward);
27
- // There is no selection position inside the limit element.
28
- if (!range) {
29
- return;
30
- }
31
- // If already at the edge of a limit element.
32
- if (range.isCollapsed) {
33
- // A collapsed selection at limit edge - nothing more to do.
34
- if (selection.isCollapsed) {
35
- return;
36
- }
37
- // A non collapsed selection is at the limit edge while expanding the selection - let others do their stuff.
38
- else if (expandSelection) {
39
- return;
40
- }
41
- }
42
- // If the range is a single line (there is no word wrapping) then move the selection to the position closest to the limit element.
43
- //
44
- // We can't move the selection directly to the isObject element (eg. table cell) because of dual position at the end/beginning
45
- // of wrapped line (it's at the same time at the end of one line and at the start of the next line).
46
- if (range.isCollapsed || isSingleLineRange(editing, range, isForward)) {
47
- model.change(writer => {
48
- const newPosition = isForward ? range.end : range.start;
49
- if (expandSelection) {
50
- const newSelection = model.createSelection(selection.anchor);
51
- newSelection.setFocus(newPosition);
52
- writer.setSelection(newSelection);
53
- }
54
- else {
55
- writer.setSelection(newPosition);
56
- }
57
- });
58
- evt.stop();
59
- data.preventDefault();
60
- data.stopPropagation();
61
- }
62
- };
63
- }
64
- /**
65
- * Finds the range between selection and closest limit element (in the direction of navigation).
66
- * The position next to limit element is adjusted to the closest allowed `$text` position.
67
- *
68
- * Returns `null` if, according to the schema, the resulting range cannot contain a `$text` element.
69
- *
70
- * @param editing The editing controller.
71
- * @param selection The current selection.
72
- * @param isForward The expected navigation direction.
73
- */
74
- function findTextRangeFromSelection(editing, selection, isForward) {
75
- const model = editing.model;
76
- if (isForward) {
77
- const startPosition = selection.focus;
78
- const endPosition = getNearestNonInlineLimit(model, startPosition, 'forward');
79
- // There is no limit element, browser should handle this.
80
- if (!endPosition) {
81
- return;
82
- }
83
- const range = model.createRange(startPosition, endPosition);
84
- const lastRangePosition = getNearestTextPosition(model.schema, range, 'backward');
85
- if (lastRangePosition) {
86
- return model.createRange(startPosition, lastRangePosition);
87
- }
88
- }
89
- else {
90
- const endPosition = selection.focus;
91
- const startPosition = getNearestNonInlineLimit(model, endPosition, 'backward');
92
- // There is no limit element, browser should handle this.
93
- if (!startPosition) {
94
- return;
95
- }
96
- const range = model.createRange(startPosition, endPosition);
97
- const firstRangePosition = getNearestTextPosition(model.schema, range, 'forward');
98
- if (firstRangePosition) {
99
- return model.createRange(firstRangePosition, endPosition);
100
- }
101
- }
102
- }
103
- /**
104
- * Finds the limit element position that is closest to startPosition.
105
- *
106
- * @param direction Search direction.
107
- */
108
- function getNearestNonInlineLimit(model, startPosition, direction) {
109
- const schema = model.schema;
110
- const range = model.createRangeIn(startPosition.root);
111
- const walkerValueType = direction == 'forward' ? 'elementStart' : 'elementEnd';
112
- for (const { previousPosition, item, type } of range.getWalker({ startPosition, direction })) {
113
- if (schema.isLimit(item) && !schema.isInline(item)) {
114
- return previousPosition;
115
- }
116
- // Stop looking for isLimit element if the next element is a block element (it is for sure not single line).
117
- if (type == walkerValueType && schema.isBlock(item)) {
118
- return null;
119
- }
120
- }
121
- return null;
122
- }
123
- /**
124
- * Basing on the provided range, finds the first or last (depending on `direction`) position inside the range
125
- * that can contain `$text` (according to schema).
126
- *
127
- * @param schema The schema.
128
- * @param range The range to find the position in.
129
- * @param direction Search direction.
130
- * @returns The nearest selection position.
131
- *
132
- */
133
- function getNearestTextPosition(schema, range, direction) {
134
- const position = direction == 'backward' ? range.end : range.start;
135
- if (schema.checkChild(position, '$text')) {
136
- return position;
137
- }
138
- for (const { nextPosition } of range.getWalker({ direction })) {
139
- if (schema.checkChild(nextPosition, '$text')) {
140
- return nextPosition;
141
- }
142
- }
143
- }
144
- /**
145
- * Checks if the DOM range corresponding to the provided model range renders as a single line by analyzing DOMRects
146
- * (verifying if they visually wrap content to the next line).
147
- *
148
- * @param editing The editing controller.
149
- * @param modelRange The current table cell content range.
150
- * @param isForward The expected navigation direction.
151
- */
152
- function isSingleLineRange(editing, modelRange, isForward) {
153
- const model = editing.model;
154
- const domConverter = editing.view.domConverter;
155
- // Wrapped lines contain exactly the same position at the end of current line
156
- // and at the beginning of next line. That position's client rect is at the end
157
- // of current line. In case of caret at first position of the last line that 'dual'
158
- // position would be detected as it's not the last line.
159
- if (isForward) {
160
- const probe = model.createSelection(modelRange.start);
161
- model.modifySelection(probe);
162
- // If the new position is at the end of the container then we can't use this position
163
- // because it would provide incorrect result for eg caption of image and selection
164
- // just before end of it. Also in this case there is no "dual" position.
165
- if (!probe.focus.isAtEnd && !modelRange.start.isEqual(probe.focus)) {
166
- modelRange = model.createRange(probe.focus, modelRange.end);
167
- }
168
- }
169
- const viewRange = editing.mapper.toViewRange(modelRange);
170
- const domRange = domConverter.viewRangeToDom(viewRange);
171
- const rects = Rect.getDomRangeRects(domRange);
172
- let boundaryVerticalPosition;
173
- for (const rect of rects) {
174
- if (boundaryVerticalPosition === undefined) {
175
- boundaryVerticalPosition = Math.round(rect.bottom);
176
- continue;
177
- }
178
- // Let's check if this rect is in new line.
179
- if (Math.round(rect.top) >= boundaryVerticalPosition) {
180
- return false;
181
- }
182
- boundaryVerticalPosition = Math.max(boundaryVerticalPosition, Math.round(rect.bottom));
183
- }
184
- return true;
185
- }