Explorar o código

Merge pull request #554 from ckeditor/i/6449

Feature: The `BlockToolbar` should group items when there is no place to show them all. Closes ckeditor/ckeditor5#6449. Closes ckeditor/ckeditor5#6575. Closes ckeditor/ckeditor5#6570.

Bulletproofed the `ToolbarView#maxWidth` and items grouping when the toolbar is invisible.
Aleksander Nowodzinski %!s(int64=5) %!d(string=hai) anos
pai
achega
8f68da15a9

+ 27 - 1
packages/ckeditor5-ui/docs/features/blocktoolbar.md

@@ -51,7 +51,7 @@ To add this feature to your editor install the [`@ckeditor/ckeditor5-ui`](https:
 npm install --save @ckeditor/ckeditor5-ui
 ```
 
-And add it to your plugin list:
+Add the `BlockToolbar` to your plugin list and configure the feature using the `blockToolbar` property:
 
 ```js
 import BlockToolbar from '@ckeditor/ckeditor5-ui/src/toolbar/block/blocktoolbar';
@@ -74,6 +74,32 @@ BalloonEditor
 	.catch( ... );
 ```
 
+You can also use the `shouldNotGroupWhenFull` option to prevent {@link module:core/editor/editorconfig~EditorConfig#toolbar automatic items grouping} in the block toolbar:
+
+```js
+import BlockToolbar from '@ckeditor/ckeditor5-ui/src/toolbar/block/blocktoolbar';
+import HeadingButtonsUI from '@ckeditor/ckeditor5-heading/src/headingbuttonsui';
+import ParagraphButtonUI from '@ckeditor/ckeditor5-paragraph/src/paragraphbuttonui';
+
+BalloonEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ BlockToolbar, ParagraphButtonUI, HeadingButtonsUI, ... ],
+		blockToolbar: {
+			items: [
+				'paragraph', 'heading1', 'heading2', 'heading3',
+				'|',
+				'bulletedList', 'numberedList',
+				'|',
+				'blockQuote', 'imageUpload'
+			],
+			shouldNotGroupWhenFull: true
+		},
+		toolbar: [ ... ]
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
 <info-box info>
 	Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
 </info-box>

+ 2 - 2
packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js

@@ -380,9 +380,9 @@ function getBalloonPositions( isBackward ) {
  *
  *		const config = {
  *			balloonToolbar: {
- *				items: [ 'bold', 'italic', 'undo', 'redo' ]
+ *				items: [ 'bold', 'italic', 'undo', 'redo' ],
+ *				shouldNotGroupWhenFull: true
  *			},
- *			shouldNotGroupWhenFull: true
  *		};
  *
  * @member {Array.<String>|Object} module:core/editor/editorconfig~EditorConfig#balloonToolbar

+ 99 - 3
packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.js

@@ -19,9 +19,15 @@ import clickOutsideHandler from '../../bindings/clickoutsidehandler';
 
 import { getOptimalPosition } from '@ckeditor/ckeditor5-utils/src/dom/position';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import normalizeToolbarConfig from '../normalizetoolbarconfig';
 
+import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver';
+
+import toUnit from '@ckeditor/ckeditor5-utils/src/dom/tounit';
 import iconPilcrow from '@ckeditor/ckeditor5-core/theme/icons/pilcrow.svg';
 
+const toPx = toUnit( 'px' );
+
 /**
  * The block toolbar plugin.
  *
@@ -77,6 +83,14 @@ export default class BlockToolbar extends Plugin {
 	constructor( editor ) {
 		super( editor );
 
+		/**
+		 * A cached and normalized `config.blockToolbar` object.
+		 *
+		 * @type {module:core/editor/editorconfig~EditorConfig#blockToolbar}
+		 * @private
+		 */
+		this._blockToolbarConfig = normalizeToolbarConfig( this.editor.config.get( 'blockToolbar' ) );
+
 		/**
 		 * The toolbar view.
 		 *
@@ -98,6 +112,20 @@ export default class BlockToolbar extends Plugin {
 		 */
 		this.buttonView = this._createButtonView();
 
+		/**
+		 * An instance of the resize observer that allows to respond to changes in editable's geometry
+		 * so the toolbar can stay within its boundaries (and group toolbar items that do not fit).
+		 *
+		 * **Note**: Used only when `shouldNotGroupWhenFull` was **not** set in the
+		 * {@link module:core/editor/editorconfig~EditorConfig#blockToolbar configuration}.
+		 *
+		 * **Note:** Created in {@link #afterInit}.
+		 *
+		 * @protected
+		 * @member {module:utils/dom/resizeobserver~ResizeObserver}
+		 */
+		this._resizeObserver = null;
+
 		// Close the #panelView upon clicking outside of the plugin UI.
 		clickOutsideHandler( {
 			emitter: this.panelView,
@@ -149,14 +177,25 @@ export default class BlockToolbar extends Plugin {
 	 */
 	afterInit() {
 		const factory = this.editor.ui.componentFactory;
-		const config = this.editor.config.get( 'blockToolbar' ) || [];
+		const config = this._blockToolbarConfig;
 
-		this.toolbarView.fillFromConfig( config, factory );
+		this.toolbarView.fillFromConfig( config.items, factory );
 
 		// Hide panel before executing each button in the panel.
 		for ( const item of this.toolbarView.items ) {
 			item.on( 'execute', () => this._hidePanel( true ), { priority: 'high' } );
 		}
+
+		if ( !config.shouldNotGroupWhenFull ) {
+			this.listenTo( this.editor, 'ready', () => {
+				const editableElement = this.editor.ui.view.editable.element;
+
+				// Set #toolbarView's max-width just after the initialization and update it on the editable resize.
+				this._resizeObserver = new ResizeObserver( editableElement, () => {
+					this.toolbarView.maxWidth = this._getToolbarMaxWidth();
+				} );
+			} );
+		}
 	}
 
 	/**
@@ -178,7 +217,10 @@ export default class BlockToolbar extends Plugin {
 	 * @returns {module:ui/toolbar/toolbarview~ToolbarView}
 	 */
 	_createToolbarView() {
-		const toolbarView = new ToolbarView( this.editor.locale );
+		const shouldGroupWhenFull = !this._blockToolbarConfig.shouldNotGroupWhenFull;
+		const toolbarView = new ToolbarView( this.editor.locale, {
+			shouldGroupWhenFull
+		} );
 
 		toolbarView.extendTemplate( {
 			attributes: {
@@ -325,6 +367,32 @@ export default class BlockToolbar extends Plugin {
 	_showPanel() {
 		const wasVisible = this.panelView.isVisible;
 
+		// So here's the thing: If there was no initial panelView#show() or these two were in different order, the toolbar
+		// positioning will break in RTL editors. Weird, right? What you show know is that the toolbar
+		// grouping works thanks to:
+		//
+		// * the ResizeObserver, which kicks in as soon as the toolbar shows up in DOM (becomes visible again).
+		// * the observable ToolbarView#maxWidth, which triggers re-grouping when changed.
+		//
+		// Here are the possible scenarios:
+		//
+		// 1. (WRONG ❌) If the #maxWidth is set when the toolbar is invisible, it won't affect item grouping (no DOMRects, no grouping).
+		//    Then, when panelView.pin() is called, the position of the toolbar will be calculated for the old
+		//    items grouping state, and when finally ResizeObserver kicks in (hey, the toolbar is visible now, right?)
+		//    it will group/ungroup some items and the length of the toolbar will change. But since in RTL the toolbar
+		//    is attached on the right side and the positioning uses CSS "left", it will result in the toolbar shifting
+		//    to the left and being displayed in the wrong place.
+		// 2. (WRONG ❌) If the panelView.pin() is called first and #maxWidth set next, then basically the story repeats. The balloon
+		//    calculates the position for the old toolbar grouping state, then the toolbar re-groups items and because
+		//    it is positioned using CSS "left" it will move.
+		// 3. (RIGHT ✅) We show the panel first (the toolbar does re-grouping but it does not matter), then the #maxWidth
+		//    is set allowing the toolbar to re-group again and finally panelView.pin() does the positioning when the
+		//    items grouping state is stable and final.
+		//
+		// https://github.com/ckeditor/ckeditor5/issues/6449, https://github.com/ckeditor/ckeditor5/issues/6575
+		this.panelView.show();
+		this.toolbarView.maxWidth = this._getToolbarMaxWidth();
+
 		this.panelView.pin( {
 			target: this.buttonView.element,
 			limiter: this.editor.ui.getEditableElement()
@@ -388,6 +456,23 @@ export default class BlockToolbar extends Plugin {
 		this.buttonView.top = position.top;
 		this.buttonView.left = position.left;
 	}
+
+	/**
+	 * Gets the {@link #toolbarView} max-width, based on
+	 * editable width plus distance between farthest edge of the {@link #buttonView} and the editable.
+	 *
+	 * @private
+	 * @returns {String} maxWidth A maximum width that toolbar can have, in pixels.
+	 */
+	_getToolbarMaxWidth() {
+		const editableElement = this.editor.ui.view.editable.element;
+		const editableRect = new Rect( editableElement );
+		const buttonRect = new Rect( this.buttonView.element );
+		const isRTL = this.editor.locale.uiLanguageDirection === 'rtl';
+		const offset = isRTL ? ( buttonRect.left - editableRect.right ) + buttonRect.width : editableRect.left - buttonRect.left;
+
+		return toPx( editableRect.width + offset );
+	}
 }
 
 /**
@@ -404,6 +489,17 @@ export default class BlockToolbar extends Plugin {
  *			blockToolbar: [ 'paragraph', 'heading1', 'heading2', '|', 'bulletedList', 'numberedList' ]
  *		};
  *
+ * ## Configuring items grouping
+ *
+ * You can prevent automatic items grouping by setting the `shouldNotGroupWhenFull` option:
+ *
+ *		const config = {
+ *			blockToolbar: {
+ *				items: [ 'paragraph', 'heading1', 'heading2', '|', 'bulletedList', 'numberedList' ],
+ *				shouldNotGroupWhenFull: true
+ *			},
+ *		};
+ *
  * Read more about configuring the main editor toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}.
  *
  * @member {Array.<String>|Object} module:core/editor/editorconfig~EditorConfig#blockToolbar

+ 25 - 2
packages/ckeditor5-ui/src/toolbar/toolbarview.js

@@ -525,6 +525,15 @@ class DynamicGrouping {
 		 */
 		this.cachedPadding = null;
 
+		/**
+		 * A flag indicating that an items grouping update has been queued (e.g. due to the toolbar being visible)
+		 * and should be executed immediately the next time the toolbar shows up.
+		 *
+		 * @readonly
+		 * @member {Boolean}
+		 */
+		this.shouldUpdateGroupingOnNextResize = false;
+
 		// Only those items that were not grouped are visible to the user.
 		view.itemsView.children.bindTo( this.ungroupedItems ).using( item => item );
 
@@ -614,11 +623,23 @@ class DynamicGrouping {
 		// Do no grouping–related geometry analysis when the toolbar is detached from visible DOM,
 		// for instance before #render(), or after render but without a parent or a parent detached
 		// from DOM. DOMRects won't work anyway and there will be tons of warning in the console and
-		// nothing else.
+		// nothing else. This happens, for instance, when the toolbar is detached from DOM and
+		// some logic adds or removes its #items.
 		if ( !this.viewElement.ownerDocument.body.contains( this.viewElement ) ) {
 			return;
 		}
 
+		// Do not update grouping when the element is invisible. Such toolbar has DOMRect filled with zeros
+		// and that would cause all items to be grouped. Instead, queue the grouping so it runs next time
+		// the toolbar is visible (the next ResizeObserver callback execution). This is handy because
+		// the grouping could be caused by increasing the #maxWidth when the toolbar was invisible and the next
+		// time it shows up, some items could actually be ungrouped (https://github.com/ckeditor/ckeditor5/issues/6575).
+		if ( !this.viewElement.offsetParent ) {
+			this.shouldUpdateGroupingOnNextResize = true;
+
+			return;
+		}
+
 		let wereItemsGrouped;
 
 		// Group #items as long as some wrap to the next row. This will happen, for instance,
@@ -701,7 +722,9 @@ class DynamicGrouping {
 
 		// TODO: Consider debounce.
 		this.resizeObserver = new ResizeObserver( this.viewElement, entry => {
-			if ( !previousWidth || previousWidth !== entry.contentRect.width ) {
+			if ( !previousWidth || previousWidth !== entry.contentRect.width || this.shouldUpdateGroupingOnNextResize ) {
+				this.shouldUpdateGroupingOnNextResize = false;
+
 				this._updateGrouping();
 
 				previousWidth = entry.contentRect.width;

+ 5 - 1
packages/ckeditor5-ui/tests/manual/blocktoolbar/blocktoolbar.js

@@ -19,7 +19,11 @@ import BlockToolbar from '../../../src/toolbar/block/blocktoolbar';
 BalloonEditor
 	.create( document.querySelector( '#editor' ), {
 		plugins: [ Essentials, List, Paragraph, Heading, Image, ImageCaption, HeadingButtonsUI, ParagraphButtonUI, BlockToolbar ],
-		blockToolbar: [ 'paragraph', 'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList' ]
+		blockToolbar: [
+			'paragraph', 'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList', 'paragraph',
+			'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList', 'paragraph', 'heading1', 'heading2', 'heading3',
+			'bulletedList', 'numberedList'
+		]
 	} )
 	.then( editor => {
 		window.editor = editor;

+ 63 - 0
packages/ckeditor5-ui/tests/manual/blocktoolbar/rtl.html

@@ -0,0 +1,63 @@
+<button class="external-type">Start external typing</button>
+<button class="external-delete">Start external deleting</button>
+
+<div class="wrapper">
+	<div id="editor">
+		<h2>The three greatest things you learn from traveling</h2>
+		<p>
+			Like all the great things on earth traveling teaches us by example. Here are some of the most precious lessons
+			I’ve learned over the years of traveling.
+		</p>
+
+		<figure class="image">
+			<img src="./umbrellas.jpg" alt="Three Monks walking on ancient temple.">
+			<figcaption>Leaving your comfort zone might lead you to such beautiful sceneries like this one.</figcaption>
+		</figure>
+
+		<h3>Appreciation of diversity</h3>
+		<p>
+			Getting used to an entirely different culture can be challenging. While it’s also nice to learn about
+			cultures online or from books, nothing comes close to experiencing cultural diversity in person.
+			You learn to appreciate each and every single one of the differences while you become more culturally fluid.
+		</p>
+
+		<h3>Improvisation</h3>
+		<p>
+			Life doesn't allow us to execute every single plan perfectly. This especially seems to be the case when
+			you travel. You plan it down to every minute with a big checklist; but when it comes to executing it,
+			something always comes up and you’re left with your improvising skills. You learn to adapt as you go.
+			Here’s how my travel checklist looks now:
+		</p>
+
+		<ul>
+			<li>buy the ticket</li>
+			<li>start your adventure</li>
+		</ul>
+
+		<h3>Confidence</h3>
+		<p>
+			Going to a new place can be quite terrifying. While change and uncertainty makes us scared, traveling
+			teaches us how ridiculous it is to be afraid of something before it happens. The moment you face your
+			fear and see there was nothing to be afraid of, is the moment you discover bliss.
+		</p>
+	</div>
+</div>
+
+<style>
+	#editor {
+		margin: 0 auto;
+		max-width: 800px;
+	}
+
+	.wrapper {
+		padding: 50px 20px;
+	}
+
+	.ck-block-toolbar-button {
+		transform: translateX( -10px );
+	}
+
+	[dir="rtl"] .ck-block-toolbar-button {
+		transform: translateX( 10px );
+	}
+</style>

+ 34 - 0
packages/ckeditor5-ui/tests/manual/blocktoolbar/rtl.js

@@ -0,0 +1,34 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window, document, console:false, */
+
+import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+import List from '@ckeditor/ckeditor5-list/src/list';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import HeadingButtonsUI from '@ckeditor/ckeditor5-heading/src/headingbuttonsui';
+import ParagraphButtonUI from '@ckeditor/ckeditor5-paragraph/src/paragraphbuttonui';
+import BlockToolbar from '../../../src/toolbar/block/blocktoolbar';
+
+BalloonEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Essentials, List, Paragraph, Heading, Image, ImageCaption, HeadingButtonsUI, ParagraphButtonUI, BlockToolbar ],
+		blockToolbar: [
+			'paragraph', 'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList', 'paragraph',
+			'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList', 'paragraph', 'heading1', 'heading2', 'heading3',
+			'bulletedList', 'numberedList'
+		],
+		language: 'ar'
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 4 - 0
packages/ckeditor5-ui/tests/manual/blocktoolbar/rtl.md

@@ -0,0 +1,4 @@
+## Block toolbar in RTL editor
+
+1. Check if the button appears on the right side of editable.
+2. Check if the toolbar shows up attached correctly to the button.

+ 145 - 0
packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js

@@ -19,13 +19,18 @@ import ParagraphButtonUI from '@ckeditor/ckeditor5-paragraph/src/paragraphbutton
 import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
 import Image from '@ckeditor/ckeditor5-image/src/image';
 import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver';
 
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+
 describe( 'BlockToolbar', () => {
 	let editor, element, blockToolbar;
+	let resizeCallback;
 
 	testUtils.createSinonSandbox();
 
@@ -33,6 +38,20 @@ describe( 'BlockToolbar', () => {
 		element = document.createElement( 'div' );
 		document.body.appendChild( element );
 
+		// Make sure other tests of the editor do not affect tests that follow.
+		// Without it, if an instance of ResizeObserver already exists somewhere undestroyed
+		// in DOM, the following DOM mock will have no effect.
+		ResizeObserver._observerInstance = null;
+
+		testUtils.sinon.stub( global.window, 'ResizeObserver' ).callsFake( callback => {
+			resizeCallback = callback;
+
+			return {
+				observe: sinon.spy(),
+				unobserve: sinon.spy()
+			};
+		} );
+
 		return ClassicTestEditor.create( element, {
 			plugins: [ BlockToolbar, Heading, HeadingButtonsUI, Paragraph, ParagraphButtonUI, BlockQuote, Image, ImageCaption ],
 			blockToolbar: [ 'paragraph', 'heading1', 'heading2', 'blockQuote' ]
@@ -64,6 +83,43 @@ describe( 'BlockToolbar', () => {
 		} );
 	} );
 
+	it( 'should accept the extended format of the toolbar config', () => {
+		return ClassicTestEditor
+			.create( element, {
+				plugins: [ BlockToolbar, Heading, HeadingButtonsUI, Paragraph, ParagraphButtonUI, BlockQuote ],
+				blockToolbar: {
+					items: [ 'paragraph', 'heading1', 'heading2', 'blockQuote' ]
+				}
+			} )
+			.then( editor => {
+				blockToolbar = editor.plugins.get( BlockToolbar );
+
+				expect( blockToolbar.toolbarView.items ).to.length( 4 );
+
+				element.remove();
+
+				return editor.destroy();
+			} );
+	} );
+
+	it( 'should not group items when the config.shouldNotGroupWhenFull option is enabled', () => {
+		return ClassicTestEditor.create( element, {
+			plugins: [ BlockToolbar, Heading, HeadingButtonsUI, Paragraph, ParagraphButtonUI, BlockQuote ],
+			blockToolbar: {
+				items: [ 'paragraph', 'heading1', 'heading2', 'blockQuote' ],
+				shouldNotGroupWhenFull: true
+			}
+		} ).then( editor => {
+			const blockToolbar = editor.plugins.get( BlockToolbar );
+
+			expect( blockToolbar.toolbarView.options.shouldGroupWhenFull ).to.be.false;
+
+			element.remove();
+
+			return editor.destroy();
+		} );
+	} );
+
 	describe( 'child views', () => {
 		describe( 'panelView', () => {
 			it( 'should create a view instance', () => {
@@ -403,6 +459,95 @@ describe( 'BlockToolbar', () => {
 			expect( blockToolbar.buttonView.left ).to.equal( 600 );
 		} );
 
+		describe( 'toolbarView#maxWidth', () => {
+			it( 'should be set when the panel shows up', () => {
+				expect( blockToolbar.toolbarView.maxWidth ).to.equal( 'auto' );
+
+				blockToolbar.buttonView.fire( 'execute' );
+
+				expect( blockToolbar.panelView.isVisible ).to.be.true;
+				expect( blockToolbar.toolbarView.maxWidth ).to.match( /.+px/ );
+			} );
+
+			it( 'should be set after the toolbar was shown (but before panel#pin()) to let the items grouping do its job', () => {
+				const maxWidthSetSpy = sinon.spy( blockToolbar.toolbarView, 'maxWidth', [ 'set' ] );
+				const panelViewShowSpy = sinon.spy( blockToolbar.panelView, 'show' );
+				const panelViewPinSpy = sinon.spy( blockToolbar.panelView, 'pin' );
+
+				blockToolbar.buttonView.fire( 'execute' );
+
+				sinon.assert.callOrder( panelViewShowSpy, maxWidthSetSpy.set, panelViewPinSpy );
+			} );
+
+			it( 'should set a proper toolbar max-width', () => {
+				const viewElement = editor.ui.getEditableElement();
+
+				testUtils.sinon.stub( viewElement, 'getBoundingClientRect' ).returns( {
+					left: 100,
+					width: 400
+				} );
+
+				testUtils.sinon.stub( blockToolbar.buttonView.element, 'getBoundingClientRect' ).returns( {
+					left: 60,
+					width: 40
+				} );
+
+				resizeCallback( [ {
+					target: viewElement,
+					contentRect: new Rect( viewElement )
+				} ] );
+
+				blockToolbar.buttonView.fire( 'execute' );
+
+				// The expected width should be equal the distance between
+				// left edge of the block toolbar button and right edge of the editable.
+				//            ---------------------------
+				//            |                         |
+				//  ____      |                         |
+				//  |__|      |        EDITABLE         |
+				//  button    |                         |
+				//            |                         |
+				//  <--------------max-width------------>
+
+				expect( blockToolbar.toolbarView.maxWidth ).to.be.equal( '440px' );
+			} );
+
+			it( 'should set a proper toolbar max-width in RTL', () => {
+				const viewElement = editor.ui.getEditableElement();
+
+				editor.locale.uiLanguageDirection = 'rtl';
+
+				testUtils.sinon.stub( viewElement, 'getBoundingClientRect' ).returns( {
+					right: 450,
+					width: 400
+				} );
+
+				testUtils.sinon.stub( blockToolbar.buttonView.element, 'getBoundingClientRect' ).returns( {
+					left: 450,
+					width: 40
+				} );
+
+				resizeCallback( [ {
+					target: viewElement,
+					contentRect: new Rect( viewElement )
+				} ] );
+
+				blockToolbar.buttonView.fire( 'execute' );
+
+				// The expected width should be equal the distance between
+				// left edge of the editable and right edge of the block toolbar button.
+				//  ---------------------------
+				//  |                         |
+				//  |                         |      ____
+				//  |        EDITABLE         |      |__|
+				//  |                         |    button
+				//  |                         |
+				//  <--------------max-width------------>
+
+				expect( blockToolbar.toolbarView.maxWidth ).to.be.equal( '440px' );
+			} );
+		} );
+
 		it( 'should reposition the #panelView when open on ui#update', () => {
 			blockToolbar.panelView.isVisible = false;
 

+ 57 - 0
packages/ckeditor5-ui/tests/toolbar/toolbarview.js

@@ -760,6 +760,63 @@ describe( 'ToolbarView', () => {
 
 				sinon.assert.calledOnce( view._behavior._updateGrouping );
 			} );
+
+			it( 'does not update the state of grouped items if invisible', () => {
+				view.element.style.width = '500px';
+				view.element.style.height = '200px';
+
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+
+				expect( ungroupedItems ).to.have.length( 5 );
+				expect( groupedItems ).to.have.length( 0 );
+
+				view.element.style.display = 'none';
+				view.maxWidth = '100px';
+
+				expect( ungroupedItems ).to.have.length( 5 );
+				expect( groupedItems ).to.have.length( 0 );
+			} );
+
+			it( 'should queue the update of the grouped items state when invisible (and execute it when visible again)', () => {
+				view.maxWidth = '200px';
+
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+
+				expect( ungroupedItems ).to.have.length( 1 );
+				expect( groupedItems ).to.have.length( 4 );
+
+				view.element.style.display = 'none';
+
+				resizeCallback( [ {
+					target: view.element,
+					contentRect: new Rect( view.element )
+				} ] );
+
+				// Response to this change will be queued.
+				view.maxWidth = '500px';
+
+				expect( ungroupedItems ).to.have.length( 1 );
+				expect( groupedItems ).to.have.length( 4 );
+
+				// The queued items state should happen after that.
+				view.element.style.display = 'flex';
+
+				resizeCallback( [ {
+					target: view.element,
+					contentRect: new Rect( view.element )
+				} ] );
+
+				expect( ungroupedItems ).to.have.length( 5 );
+				expect( groupedItems ).to.have.length( 0 );
+			} );
 		} );
 
 		describe( 'destroy()', () => {