@@ -19,9 +19,15 @@ import clickOutsideHandler from '../../bindings/clickoutsidehandler';
1919
2020import { getOptimalPosition } from '@ckeditor/ckeditor5-utils/src/dom/position' ;
2121import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect' ;
22+ import normalizeToolbarConfig from '../normalizetoolbarconfig' ;
2223
24+ import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver' ;
25+
26+ import toUnit from '@ckeditor/ckeditor5-utils/src/dom/tounit' ;
2327import iconPilcrow from '@ckeditor/ckeditor5-core/theme/icons/pilcrow.svg' ;
2428
29+ const toPx = toUnit ( 'px' ) ;
30+
2531/**
2632 * The block toolbar plugin.
2733 *
@@ -77,6 +83,14 @@ export default class BlockToolbar extends Plugin {
7783 constructor ( editor ) {
7884 super ( editor ) ;
7985
86+ /**
87+ * A cached and normalized `config.blockToolbar` object.
88+ *
89+ * @type {module:core/editor/editorconfig~EditorConfig#blockToolbar }
90+ * @private
91+ */
92+ this . _blockToolbarConfig = normalizeToolbarConfig ( this . editor . config . get ( 'blockToolbar' ) ) ;
93+
8094 /**
8195 * The toolbar view.
8296 *
@@ -98,6 +112,20 @@ export default class BlockToolbar extends Plugin {
98112 */
99113 this . buttonView = this . _createButtonView ( ) ;
100114
115+ /**
116+ * An instance of the resize observer that allows to respond to changes in editable's geometry
117+ * so the toolbar can stay within its boundaries (and group toolbar items that do not fit).
118+ *
119+ * **Note**: Used only when `shouldNotGroupWhenFull` was **not** set in the
120+ * {@link module:core/editor/editorconfig~EditorConfig#blockToolbar configuration}.
121+ *
122+ * **Note:** Created in {@link #afterInit}.
123+ *
124+ * @protected
125+ * @member {module:utils/dom/resizeobserver~ResizeObserver}
126+ */
127+ this . _resizeObserver = null ;
128+
101129 // Close the #panelView upon clicking outside of the plugin UI.
102130 clickOutsideHandler ( {
103131 emitter : this . panelView ,
@@ -149,14 +177,25 @@ export default class BlockToolbar extends Plugin {
149177 */
150178 afterInit ( ) {
151179 const factory = this . editor . ui . componentFactory ;
152- const config = this . editor . config . get ( 'blockToolbar' ) || [ ] ;
180+ const config = this . _blockToolbarConfig ;
153181
154- this . toolbarView . fillFromConfig ( config , factory ) ;
182+ this . toolbarView . fillFromConfig ( config . items , factory ) ;
155183
156184 // Hide panel before executing each button in the panel.
157185 for ( const item of this . toolbarView . items ) {
158186 item . on ( 'execute' , ( ) => this . _hidePanel ( true ) , { priority : 'high' } ) ;
159187 }
188+
189+ if ( ! config . shouldNotGroupWhenFull ) {
190+ this . listenTo ( this . editor , 'ready' , ( ) => {
191+ const editableElement = this . editor . ui . view . editable . element ;
192+
193+ // Set #toolbarView's max-width just after the initialization and update it on the editable resize.
194+ this . _resizeObserver = new ResizeObserver ( editableElement , ( ) => {
195+ this . toolbarView . maxWidth = this . _getToolbarMaxWidth ( ) ;
196+ } ) ;
197+ } ) ;
198+ }
160199 }
161200
162201 /**
@@ -178,7 +217,10 @@ export default class BlockToolbar extends Plugin {
178217 * @returns {module:ui/toolbar/toolbarview~ToolbarView }
179218 */
180219 _createToolbarView ( ) {
181- const toolbarView = new ToolbarView ( this . editor . locale ) ;
220+ const shouldGroupWhenFull = ! this . _blockToolbarConfig . shouldNotGroupWhenFull ;
221+ const toolbarView = new ToolbarView ( this . editor . locale , {
222+ shouldGroupWhenFull
223+ } ) ;
182224
183225 toolbarView . extendTemplate ( {
184226 attributes : {
@@ -325,6 +367,32 @@ export default class BlockToolbar extends Plugin {
325367 _showPanel ( ) {
326368 const wasVisible = this . panelView . isVisible ;
327369
370+ // So here's the thing: If there was no initial panelView#show() or these two were in different order, the toolbar
371+ // positioning will break in RTL editors. Weird, right? What you show know is that the toolbar
372+ // grouping works thanks to:
373+ //
374+ // * the ResizeObserver, which kicks in as soon as the toolbar shows up in DOM (becomes visible again).
375+ // * the observable ToolbarView#maxWidth, which triggers re-grouping when changed.
376+ //
377+ // Here are the possible scenarios:
378+ //
379+ // 1. (WRONG ❌) If the #maxWidth is set when the toolbar is invisible, it won't affect item grouping (no DOMRects, no grouping).
380+ // Then, when panelView.pin() is called, the position of the toolbar will be calculated for the old
381+ // items grouping state, and when finally ResizeObserver kicks in (hey, the toolbar is visible now, right?)
382+ // it will group/ungroup some items and the length of the toolbar will change. But since in RTL the toolbar
383+ // is attached on the right side and the positioning uses CSS "left", it will result in the toolbar shifting
384+ // to the left and being displayed in the wrong place.
385+ // 2. (WRONG ❌) If the panelView.pin() is called first and #maxWidth set next, then basically the story repeats. The balloon
386+ // calculates the position for the old toolbar grouping state, then the toolbar re-groups items and because
387+ // it is positioned using CSS "left" it will move.
388+ // 3. (RIGHT ✅) We show the panel first (the toolbar does re-grouping but it does not matter), then the #maxWidth
389+ // is set allowing the toolbar to re-group again and finally panelView.pin() does the positioning when the
390+ // items grouping state is stable and final.
391+ //
392+ // https://github.com/ckeditor/ckeditor5/issues/6449, https://github.com/ckeditor/ckeditor5/issues/6575
393+ this . panelView . show ( ) ;
394+ this . toolbarView . maxWidth = this . _getToolbarMaxWidth ( ) ;
395+
328396 this . panelView . pin ( {
329397 target : this . buttonView . element ,
330398 limiter : this . editor . ui . getEditableElement ( )
@@ -388,6 +456,23 @@ export default class BlockToolbar extends Plugin {
388456 this . buttonView . top = position . top ;
389457 this . buttonView . left = position . left ;
390458 }
459+
460+ /**
461+ * Gets the {@link #toolbarView} max-width, based on
462+ * editable width plus distance between farthest edge of the {@link #buttonView} and the editable.
463+ *
464+ * @private
465+ * @returns {String } maxWidth A maximum width that toolbar can have, in pixels.
466+ */
467+ _getToolbarMaxWidth ( ) {
468+ const editableElement = this . editor . ui . view . editable . element ;
469+ const editableRect = new Rect ( editableElement ) ;
470+ const buttonRect = new Rect ( this . buttonView . element ) ;
471+ const isRTL = this . editor . locale . uiLanguageDirection === 'rtl' ;
472+ const offset = isRTL ? ( buttonRect . left - editableRect . right ) + buttonRect . width : editableRect . left - buttonRect . left ;
473+
474+ return toPx ( editableRect . width + offset ) ;
475+ }
391476}
392477
393478/**
@@ -404,6 +489,17 @@ export default class BlockToolbar extends Plugin {
404489 * blockToolbar: [ 'paragraph', 'heading1', 'heading2', '|', 'bulletedList', 'numberedList' ]
405490 * };
406491 *
492+ * ## Configuring items grouping
493+ *
494+ * You can prevent automatic items grouping by setting the `shouldNotGroupWhenFull` option:
495+ *
496+ * const config = {
497+ * blockToolbar: {
498+ * items: [ 'paragraph', 'heading1', 'heading2', '|', 'bulletedList', 'numberedList' ],
499+ * shouldNotGroupWhenFull: true
500+ * },
501+ * };
502+ *
407503 * Read more about configuring the main editor toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}.
408504 *
409505 * @member {Array.<String>|Object} module:core/editor/editorconfig~EditorConfig#blockToolbar
0 commit comments