Skip to content
This repository was archived by the owner on Jun 26, 2020. It is now read-only.

Commit fcd9c4e

Browse files
authored
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.
2 parents 3c25da5 + b9fedee commit fcd9c4e

File tree

10 files changed

+461
-9
lines changed

10 files changed

+461
-9
lines changed

docs/features/blocktoolbar.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ To add this feature to your editor install the [`@ckeditor/ckeditor5-ui`](https:
5151
npm install --save @ckeditor/ckeditor5-ui
5252
```
5353

54-
And add it to your plugin list:
54+
Add the `BlockToolbar` to your plugin list and configure the feature using the `blockToolbar` property:
5555

5656
```js
5757
import BlockToolbar from '@ckeditor/ckeditor5-ui/src/toolbar/block/blocktoolbar';
@@ -74,6 +74,32 @@ BalloonEditor
7474
.catch( ... );
7575
```
7676

77+
You can also use the `shouldNotGroupWhenFull` option to prevent {@link module:core/editor/editorconfig~EditorConfig#toolbar automatic items grouping} in the block toolbar:
78+
79+
```js
80+
import BlockToolbar from '@ckeditor/ckeditor5-ui/src/toolbar/block/blocktoolbar';
81+
import HeadingButtonsUI from '@ckeditor/ckeditor5-heading/src/headingbuttonsui';
82+
import ParagraphButtonUI from '@ckeditor/ckeditor5-paragraph/src/paragraphbuttonui';
83+
84+
BalloonEditor
85+
.create( document.querySelector( '#editor' ), {
86+
plugins: [ BlockToolbar, ParagraphButtonUI, HeadingButtonsUI, ... ],
87+
blockToolbar: {
88+
items: [
89+
'paragraph', 'heading1', 'heading2', 'heading3',
90+
'|',
91+
'bulletedList', 'numberedList',
92+
'|',
93+
'blockQuote', 'imageUpload'
94+
],
95+
shouldNotGroupWhenFull: true
96+
},
97+
toolbar: [ ... ]
98+
} )
99+
.then( ... )
100+
.catch( ... );
101+
```
102+
77103
<info-box info>
78104
Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
79105
</info-box>

src/toolbar/balloon/balloontoolbar.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,9 +380,9 @@ function getBalloonPositions( isBackward ) {
380380
*
381381
* const config = {
382382
* balloonToolbar: {
383-
* items: [ 'bold', 'italic', 'undo', 'redo' ]
383+
* items: [ 'bold', 'italic', 'undo', 'redo' ],
384+
* shouldNotGroupWhenFull: true
384385
* },
385-
* shouldNotGroupWhenFull: true
386386
* };
387387
*
388388
* @member {Array.<String>|Object} module:core/editor/editorconfig~EditorConfig#balloonToolbar

src/toolbar/block/blocktoolbar.js

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,15 @@ import clickOutsideHandler from '../../bindings/clickoutsidehandler';
1919

2020
import { getOptimalPosition } from '@ckeditor/ckeditor5-utils/src/dom/position';
2121
import 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';
2327
import 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

src/toolbar/toolbarview.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,15 @@ class DynamicGrouping {
525525
*/
526526
this.cachedPadding = null;
527527

528+
/**
529+
* A flag indicating that an items grouping update has been queued (e.g. due to the toolbar being visible)
530+
* and should be executed immediately the next time the toolbar shows up.
531+
*
532+
* @readonly
533+
* @member {Boolean}
534+
*/
535+
this.shouldUpdateGroupingOnNextResize = false;
536+
528537
// Only those items that were not grouped are visible to the user.
529538
view.itemsView.children.bindTo( this.ungroupedItems ).using( item => item );
530539

@@ -614,11 +623,23 @@ class DynamicGrouping {
614623
// Do no grouping–related geometry analysis when the toolbar is detached from visible DOM,
615624
// for instance before #render(), or after render but without a parent or a parent detached
616625
// from DOM. DOMRects won't work anyway and there will be tons of warning in the console and
617-
// nothing else.
626+
// nothing else. This happens, for instance, when the toolbar is detached from DOM and
627+
// some logic adds or removes its #items.
618628
if ( !this.viewElement.ownerDocument.body.contains( this.viewElement ) ) {
619629
return;
620630
}
621631

632+
// Do not update grouping when the element is invisible. Such toolbar has DOMRect filled with zeros
633+
// and that would cause all items to be grouped. Instead, queue the grouping so it runs next time
634+
// the toolbar is visible (the next ResizeObserver callback execution). This is handy because
635+
// the grouping could be caused by increasing the #maxWidth when the toolbar was invisible and the next
636+
// time it shows up, some items could actually be ungrouped (https://github.com/ckeditor/ckeditor5/issues/6575).
637+
if ( !this.viewElement.offsetParent ) {
638+
this.shouldUpdateGroupingOnNextResize = true;
639+
640+
return;
641+
}
642+
622643
let wereItemsGrouped;
623644

624645
// Group #items as long as some wrap to the next row. This will happen, for instance,
@@ -701,7 +722,9 @@ class DynamicGrouping {
701722

702723
// TODO: Consider debounce.
703724
this.resizeObserver = new ResizeObserver( this.viewElement, entry => {
704-
if ( !previousWidth || previousWidth !== entry.contentRect.width ) {
725+
if ( !previousWidth || previousWidth !== entry.contentRect.width || this.shouldUpdateGroupingOnNextResize ) {
726+
this.shouldUpdateGroupingOnNextResize = false;
727+
705728
this._updateGrouping();
706729

707730
previousWidth = entry.contentRect.width;

tests/manual/blocktoolbar/blocktoolbar.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import BlockToolbar from '../../../src/toolbar/block/blocktoolbar';
1919
BalloonEditor
2020
.create( document.querySelector( '#editor' ), {
2121
plugins: [ Essentials, List, Paragraph, Heading, Image, ImageCaption, HeadingButtonsUI, ParagraphButtonUI, BlockToolbar ],
22-
blockToolbar: [ 'paragraph', 'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList' ]
22+
blockToolbar: [
23+
'paragraph', 'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList', 'paragraph',
24+
'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList', 'paragraph', 'heading1', 'heading2', 'heading3',
25+
'bulletedList', 'numberedList'
26+
]
2327
} )
2428
.then( editor => {
2529
window.editor = editor;

tests/manual/blocktoolbar/rtl.html

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<button class="external-type">Start external typing</button>
2+
<button class="external-delete">Start external deleting</button>
3+
4+
<div class="wrapper">
5+
<div id="editor">
6+
<h2>The three greatest things you learn from traveling</h2>
7+
<p>
8+
Like all the great things on earth traveling teaches us by example. Here are some of the most precious lessons
9+
I’ve learned over the years of traveling.
10+
</p>
11+
12+
<figure class="image">
13+
<img src="./umbrellas.jpg" alt="Three Monks walking on ancient temple.">
14+
<figcaption>Leaving your comfort zone might lead you to such beautiful sceneries like this one.</figcaption>
15+
</figure>
16+
17+
<h3>Appreciation of diversity</h3>
18+
<p>
19+
Getting used to an entirely different culture can be challenging. While it’s also nice to learn about
20+
cultures online or from books, nothing comes close to experiencing cultural diversity in person.
21+
You learn to appreciate each and every single one of the differences while you become more culturally fluid.
22+
</p>
23+
24+
<h3>Improvisation</h3>
25+
<p>
26+
Life doesn't allow us to execute every single plan perfectly. This especially seems to be the case when
27+
you travel. You plan it down to every minute with a big checklist; but when it comes to executing it,
28+
something always comes up and you’re left with your improvising skills. You learn to adapt as you go.
29+
Here’s how my travel checklist looks now:
30+
</p>
31+
32+
<ul>
33+
<li>buy the ticket</li>
34+
<li>start your adventure</li>
35+
</ul>
36+
37+
<h3>Confidence</h3>
38+
<p>
39+
Going to a new place can be quite terrifying. While change and uncertainty makes us scared, traveling
40+
teaches us how ridiculous it is to be afraid of something before it happens. The moment you face your
41+
fear and see there was nothing to be afraid of, is the moment you discover bliss.
42+
</p>
43+
</div>
44+
</div>
45+
46+
<style>
47+
#editor {
48+
margin: 0 auto;
49+
max-width: 800px;
50+
}
51+
52+
.wrapper {
53+
padding: 50px 20px;
54+
}
55+
56+
.ck-block-toolbar-button {
57+
transform: translateX( -10px );
58+
}
59+
60+
[dir="rtl"] .ck-block-toolbar-button {
61+
transform: translateX( 10px );
62+
}
63+
</style>

tests/manual/blocktoolbar/rtl.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
3+
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4+
*/
5+
6+
/* globals window, document, console:false, */
7+
8+
import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor';
9+
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
10+
import List from '@ckeditor/ckeditor5-list/src/list';
11+
import Image from '@ckeditor/ckeditor5-image/src/image';
12+
import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
13+
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
14+
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
15+
import HeadingButtonsUI from '@ckeditor/ckeditor5-heading/src/headingbuttonsui';
16+
import ParagraphButtonUI from '@ckeditor/ckeditor5-paragraph/src/paragraphbuttonui';
17+
import BlockToolbar from '../../../src/toolbar/block/blocktoolbar';
18+
19+
BalloonEditor
20+
.create( document.querySelector( '#editor' ), {
21+
plugins: [ Essentials, List, Paragraph, Heading, Image, ImageCaption, HeadingButtonsUI, ParagraphButtonUI, BlockToolbar ],
22+
blockToolbar: [
23+
'paragraph', 'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList', 'paragraph',
24+
'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList', 'paragraph', 'heading1', 'heading2', 'heading3',
25+
'bulletedList', 'numberedList'
26+
],
27+
language: 'ar'
28+
} )
29+
.then( editor => {
30+
window.editor = editor;
31+
} )
32+
.catch( err => {
33+
console.error( err.stack );
34+
} );

tests/manual/blocktoolbar/rtl.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
## Block toolbar in RTL editor
2+
3+
1. Check if the button appears on the right side of editable.
4+
2. Check if the toolbar shows up attached correctly to the button.

0 commit comments

Comments
 (0)