UNPKG

@ckeditor/ckeditor5-ui

Version:

The UI framework and standard UI library of CKEditor 5.

871 lines (870 loc) 39.5 kB
/** * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * @module ui/toolbar/toolbarview */ import View from '../view.js'; import FocusCycler, { isFocusable } from '../focuscycler.js'; import ToolbarSeparatorView from './toolbarseparatorview.js'; import ToolbarLineBreakView from './toolbarlinebreakview.js'; import preventDefault from '../bindings/preventdefault.js'; import { createDropdown, addToolbarToDropdown } from '../dropdown/utils.js'; import normalizeToolbarConfig from './normalizetoolbarconfig.js'; import { FocusTracker, KeystrokeHandler, Rect, ResizeObserver, global, isVisible, logWarning } from '@ckeditor/ckeditor5-utils'; import { IconAlignLeft, IconBold, IconImportExport, IconParagraph, IconPlus, IconText, IconThreeVerticalDots, IconPilcrow, IconDragIndicator } from '@ckeditor/ckeditor5-icons'; import { isObject } from 'es-toolkit/compat'; import '../../theme/components/toolbar/toolbar.css'; export const NESTED_TOOLBAR_ICONS = /* #__PURE__ */ (() => ({ alignLeft: IconAlignLeft, bold: IconBold, importExport: IconImportExport, paragraph: IconParagraph, plus: IconPlus, text: IconText, threeVerticalDots: IconThreeVerticalDots, pilcrow: IconPilcrow, dragIndicator: IconDragIndicator }))(); /** * The toolbar view class. */ export default class ToolbarView extends View { /** * A reference to the options object passed to the constructor. */ options; /** * A collection of toolbar items (buttons, dropdowns, etc.). */ items; /** * Tracks information about the DOM focus in the toolbar. */ focusTracker; /** * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler} * to handle keyboard navigation in the toolbar. */ keystrokes; /** * A (child) view containing {@link #items toolbar items}. */ itemsView; /** * A top–level collection aggregating building blocks of the toolbar. * * ┌───────────────── ToolbarView ─────────────────┐ * | ┌──────────────── #children ────────────────┐ | * | | ┌──────────── #itemsView ───────────┐ | | * | | | [ item1 ] [ item2 ] ... [ itemN ] | | | * | | └──────────────────────────────────-┘ | | * | └───────────────────────────────────────────┘ | * └───────────────────────────────────────────────┘ * * By default, it contains the {@link #itemsView} but it can be extended with additional * UI elements when necessary. */ children; /** * A collection of {@link #items} that take part in the focus cycling * (i.e. navigation using the keyboard). Usually, it contains a subset of {@link #items} with * some optional UI elements that also belong to the toolbar and should be focusable * by the user. */ focusables; /** * Helps cycling over {@link #focusables focusable items} in the toolbar. */ _focusCycler; /** * An instance of the active toolbar behavior that shapes its look and functionality. * * See {@link module:ui/toolbar/toolbarview~ToolbarBehavior} to learn more. */ _behavior; /** * Creates an instance of the {@link module:ui/toolbar/toolbarview~ToolbarView} class. * * Also see {@link #render}. * * @param locale The localization services instance. * @param options Configuration options of the toolbar. */ constructor(locale, options) { super(locale); const bind = this.bindTemplate; const t = this.t; this.options = options || {}; this.set('ariaLabel', t('Editor toolbar')); this.set('maxWidth', 'auto'); this.set('role', 'toolbar'); this.set('isGrouping', !!this.options.shouldGroupWhenFull); this.items = this.createCollection(); this.focusTracker = new FocusTracker(); this.keystrokes = new KeystrokeHandler(); this.set('class', undefined); this.set('isCompact', false); // Static toolbar can be vertical when needed. this.set('isVertical', false); this.itemsView = new ItemsView(locale); this.children = this.createCollection(); this.children.add(this.itemsView); this.focusables = this.createCollection(); const isRtl = locale.uiLanguageDirection === 'rtl'; this._focusCycler = new FocusCycler({ focusables: this.focusables, focusTracker: this.focusTracker, keystrokeHandler: this.keystrokes, actions: { // Navigate toolbar items backwards using the arrow[left,up] keys. focusPrevious: [isRtl ? 'arrowright' : 'arrowleft', 'arrowup'], // Navigate toolbar items forwards using the arrow[right,down] keys. focusNext: [isRtl ? 'arrowleft' : 'arrowright', 'arrowdown'] } }); const classes = [ 'ck', 'ck-toolbar', bind.to('class'), bind.if('isCompact', 'ck-toolbar_compact'), // To group items dynamically, the toolbar needs a dedicated CSS class. Only used for dynamic grouping. bind.if('isGrouping', 'ck-toolbar_grouping'), // When vertical, the toolbar has an additional CSS class. Only used for static layout. bind.if('isVertical', 'ck-toolbar_vertical') ]; if (this.options.shouldGroupWhenFull && this.options.isFloating) { classes.push('ck-toolbar_floating'); } this.setTemplate({ tag: 'div', attributes: { class: classes, role: bind.to('role'), 'aria-label': bind.to('ariaLabel'), style: { maxWidth: bind.to('maxWidth') }, tabindex: -1 }, children: this.children, on: { // https://github.com/ckeditor/ckeditor5-ui/issues/206 mousedown: preventDefault(this) } }); this._behavior = this.options.shouldGroupWhenFull ? new DynamicGrouping(this) : new StaticLayout(this); } /** * @inheritDoc */ render() { super.render(); this.focusTracker.add(this.element); // Children added before rendering should be known to the #focusTracker. for (const item of this.items) { this.focusTracker.add(item); } this.items.on('add', (evt, item) => { this.focusTracker.add(item); }); this.items.on('remove', (evt, item) => { this.focusTracker.remove(item); }); // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo(this.element); this._behavior.render(this); } /** * @inheritDoc */ destroy() { this._behavior.destroy(); this.focusTracker.destroy(); this.keystrokes.destroy(); return super.destroy(); } /** * Focuses the first focusable in {@link #focusables}. */ focus() { this._focusCycler.focusFirst(); } /** * Focuses the last focusable in {@link #focusables}. */ focusLast() { this._focusCycler.focusLast(); } /** * A utility that expands the plain toolbar configuration into * {@link module:ui/toolbar/toolbarview~ToolbarView#items} using a given component factory. * * @param itemsOrConfig The toolbar items or the entire toolbar configuration object. * @param factory A factory producing toolbar items. * @param removeItems An array of items names to be removed from the configuration. When present, applies * to this toolbar and all nested ones as well. */ fillFromConfig(itemsOrConfig, factory, removeItems) { this.items.addMany(this._buildItemsFromConfig(itemsOrConfig, factory, removeItems)); } /** * Changes the behavior of toolbar if it does not fit into the available space. */ switchBehavior(newBehaviorType) { if (this._behavior.type !== newBehaviorType) { this._behavior.destroy(); this.itemsView.children.clear(); this.focusables.clear(); if (newBehaviorType === 'dynamic') { this._behavior = new DynamicGrouping(this); this._behavior.render(this); this._behavior.refreshItems(); } else { this._behavior = new StaticLayout(this); this._behavior.render(this); } } } /** * A utility that expands the plain toolbar configuration into a list of view items using a given component factory. * * @param itemsOrConfig The toolbar items or the entire toolbar configuration object. * @param factory A factory producing toolbar items. * @param removeItems An array of items names to be removed from the configuration. When present, applies * to this toolbar and all nested ones as well. */ _buildItemsFromConfig(itemsOrConfig, factory, removeItems) { const config = normalizeToolbarConfig(itemsOrConfig); const normalizedRemoveItems = removeItems || config.removeItems; const itemsToAdd = this._cleanItemsConfiguration(config.items, factory, normalizedRemoveItems) .map(item => { if (isObject(item)) { return this._createNestedToolbarDropdown(item, factory, normalizedRemoveItems); } else if (item === '|') { return new ToolbarSeparatorView(); } else if (item === '-') { return new ToolbarLineBreakView(); } return factory.create(item); }) .filter((item) => !!item); return itemsToAdd; } /** * Cleans up the {@link module:ui/toolbar/toolbarview~ToolbarView#items} of the toolbar by removing unwanted items and * duplicated (obsolete) separators or line breaks. * * @param items The toolbar items configuration. * @param factory A factory producing toolbar items. * @param removeItems An array of items names to be removed from the configuration. * @returns Items after the clean-up. */ _cleanItemsConfiguration(items, factory, removeItems) { const filteredItems = items .filter((item, idx, items) => { if (item === '|') { return true; } // Items listed in `config.removeItems` should not be added to the toolbar. if (removeItems.indexOf(item) !== -1) { return false; } if (item === '-') { // The toolbar line breaks must not be rendered when toolbar grouping is enabled. // (https://github.com/ckeditor/ckeditor5/issues/8582) if (this.options.shouldGroupWhenFull) { /** * The toolbar multiline breaks (`-` items) only work when the automatic button grouping * is disabled in the toolbar configuration. * To do this, set the `shouldNotGroupWhenFull` option to `true` in the editor configuration: * * ```ts * const config = { * toolbar: { * items: [ ... ], * shouldNotGroupWhenFull: true * } * } * ``` * * Learn more about {@link module:core/editor/editorconfig~EditorConfig#toolbar toolbar configuration}. * * @error toolbarview-line-break-ignored-when-grouping-items */ logWarning('toolbarview-line-break-ignored-when-grouping-items', items); return false; } return true; } // For the items that cannot be instantiated we are sending warning message. We also filter them out. if (!isObject(item) && !factory.has(item)) { /** * There was a problem processing the configuration of the toolbar. The item with the given * name does not exist so it was omitted when rendering the toolbar. * * This warning usually shows up when the {@link module:core/plugin~Plugin} which is supposed * to provide a toolbar item has not been loaded or there is a typo in the * {@link module:core/editor/editorconfig~EditorConfig#toolbar toolbar configuration}. * * Make sure the plugin responsible for this toolbar item is loaded and the toolbar configuration * is correct, e.g. {@link module:basic-styles/bold~Bold} is loaded for the `'bold'` toolbar item. * * You can use the following snippet to retrieve all available toolbar items: * * ```ts * Array.from( editor.ui.componentFactory.names() ); * ``` * * @error toolbarview-item-unavailable * @param {string} item The name of the component or nested toolbar definition. */ logWarning('toolbarview-item-unavailable', { item }); return false; } return true; }); return this._cleanSeparatorsAndLineBreaks(filteredItems); } /** * Remove leading, trailing, and duplicated separators (`-` and `|`). * * @returns Toolbar items after the separator and line break clean-up. */ _cleanSeparatorsAndLineBreaks(items) { const nonSeparatorPredicate = (item) => (item !== '-' && item !== '|'); const count = items.length; // Find an index of the first item that is not a separator. const firstCommandItemIndex = items.findIndex(nonSeparatorPredicate); // Items include separators only. There is no point in displaying them. if (firstCommandItemIndex === -1) { return []; } // Search from the end of the list, then convert found index back to the original direction. const lastCommandItemIndex = count - items .slice() .reverse() .findIndex(nonSeparatorPredicate); return items // Return items without the leading and trailing separators. .slice(firstCommandItemIndex, lastCommandItemIndex) // Remove duplicated separators. .filter((name, idx, items) => { // Filter only separators. if (nonSeparatorPredicate(name)) { return true; } const isDuplicated = idx > 0 && items[idx - 1] === name; return !isDuplicated; }); } /** * Creates a user-defined dropdown containing a toolbar with items. * * @param definition A definition of the nested toolbar dropdown. * @param definition.label A label of the dropdown. * @param definition.icon An icon of the drop-down. One of 'bold', 'plus', 'text', 'importExport', 'alignLeft', * 'paragraph' or an SVG string. When `false` is passed, no icon will be used. * @param definition.withText When set `true`, the label of the dropdown will be visible. See * {@link module:ui/button/buttonview~ButtonView#withText} to learn more. * @param definition.tooltip A tooltip of the dropdown button. See * {@link module:ui/button/buttonview~ButtonView#tooltip} to learn more. Defaults to `true`. * @param componentFactory Component factory used to create items * of the nested toolbar. */ _createNestedToolbarDropdown(definition, componentFactory, removeItems) { let { label, icon, items, tooltip = true, withText = false } = definition; items = this._cleanItemsConfiguration(items, componentFactory, removeItems); // There is no point in rendering a dropdown without items. if (!items.length) { return null; } const locale = this.locale; const dropdownView = createDropdown(locale); if (!label) { /** * A dropdown definition in the toolbar configuration is missing a text label. * * Without a label, the dropdown becomes inaccessible to users relying on assistive technologies. * Make sure the `label` property is set in your drop-down configuration: * * ```json * { * label: 'A human-readable label', * icon: '...', * items: [ ... ] * }, * ``` * * Learn more about {@link module:core/editor/editorconfig~EditorConfig#toolbar toolbar configuration}. * * @error toolbarview-nested-toolbar-dropdown-missing-label */ logWarning('toolbarview-nested-toolbar-dropdown-missing-label', definition); } dropdownView.class = 'ck-toolbar__nested-toolbar-dropdown'; dropdownView.buttonView.set({ label, tooltip, withText: !!withText }); // Allow disabling icon by passing false. if (icon !== false) { // A pre-defined icon picked by name, SVG string, a fallback (default) icon. dropdownView.buttonView.icon = NESTED_TOOLBAR_ICONS[icon] || icon || IconThreeVerticalDots; } // If the icon is disabled, display the label automatically. else { dropdownView.buttonView.withText = true; } addToolbarToDropdown(dropdownView, () => (dropdownView.toolbarView._buildItemsFromConfig(items, componentFactory, removeItems))); return dropdownView; } } /** * An inner block of the {@link module:ui/toolbar/toolbarview~ToolbarView} hosting its * {@link module:ui/toolbar/toolbarview~ToolbarView#items}. */ class ItemsView extends View { /** * A collection of items (buttons, dropdowns, etc.). */ children; /** * @inheritDoc */ constructor(locale) { super(locale); this.children = this.createCollection(); this.setTemplate({ tag: 'div', attributes: { class: [ 'ck', 'ck-toolbar__items' ] }, children: this.children }); } } /** * A toolbar behavior that makes it static and unresponsive to the changes of the environment. * At the same time, it also makes it possible to display a toolbar with a vertical layout * using the {@link module:ui/toolbar/toolbarview~ToolbarView#isVertical} property. */ class StaticLayout { /** * Toolbar behavior type. */ type = 'static'; /** * Creates an instance of the {@link module:ui/toolbar/toolbarview~StaticLayout} toolbar * behavior. * * @param view An instance of the toolbar that this behavior is added to. */ constructor(view) { view.isGrouping = false; // 1:1 pass–through binding, all ToolbarView#items are visible. view.itemsView.children.bindTo(view.items).using(item => item); // 1:1 pass–through binding, all ToolbarView#items are focusable. view.focusables.bindTo(view.items).using(item => isFocusable(item) ? item : null); } /** * @inheritDoc */ render() { } /** * @inheritDoc */ destroy() { } } /** * A toolbar behavior that makes the items respond to changes in the geometry. * * In a nutshell, it groups {@link module:ui/toolbar/toolbarview~ToolbarView#items} * that do not fit visually into a single row of the toolbar (due to limited space). * Items that do not fit are aggregated in a dropdown displayed at the end of the toolbar. * * ``` * ┌──────────────────────────────────────── ToolbarView ──────────────────────────────────────────┐ * | ┌─────────────────────────────────────── #children ─────────────────────────────────────────┐ | * | | ┌─────── #itemsView ────────┐ ┌──────────────────────┐ ┌── #groupedItemsDropdown ───┐ | | * | | | #ungroupedItems | | ToolbarSeparatorView | | #groupedItems | | | * | | └──────────────────────────-┘ └──────────────────────┘ └────────────────────────────┘ | | * | | \---------- only when toolbar items overflow -------/ | | * | └───────────────────────────────────────────────────────────────────────────────────────────┘ | * └───────────────────────────────────────────────────────────────────────────────────────────────┘ * ``` */ class DynamicGrouping { /** * Toolbar behavior type. */ type = 'dynamic'; /** * A toolbar view this behavior belongs to. */ view; /** * A collection of toolbar children. */ viewChildren; /** * A collection of focusable toolbar elements. */ viewFocusables; /** * A view containing toolbar items. */ viewItemsView; /** * Toolbar focus tracker. */ viewFocusTracker; /** * Toolbar locale. */ viewLocale; /** * A subset of toolbar {@link module:ui/toolbar/toolbarview~ToolbarView#items}. * Aggregates items that fit into a single row of the toolbar and were not {@link #groupedItems grouped} * into a {@link #groupedItemsDropdown dropdown}. Items of this collection are displayed in the * {@link module:ui/toolbar/toolbarview~ToolbarView#itemsView}. * * When none of the {@link module:ui/toolbar/toolbarview~ToolbarView#items} were grouped, it * matches the {@link module:ui/toolbar/toolbarview~ToolbarView#items} collection in size and order. */ ungroupedItems; /** * A subset of toolbar {@link module:ui/toolbar/toolbarview~ToolbarView#items}. * A collection of the toolbar items that do not fit into a single row of the toolbar. * Grouped items are displayed in a dedicated {@link #groupedItemsDropdown dropdown}. * * When none of the {@link module:ui/toolbar/toolbarview~ToolbarView#items} were grouped, * this collection is empty. */ groupedItems; /** * The dropdown that aggregates {@link #groupedItems grouped items} that do not fit into a single * row of the toolbar. It is displayed on demand as the last of * {@link module:ui/toolbar/toolbarview~ToolbarView#children toolbar children} and offers another * (nested) toolbar which displays items that would normally overflow. */ groupedItemsDropdown; /** * An instance of the resize observer that helps dynamically determine the geometry of the toolbar * and manage items that do not fit into a single row. * * **Note:** Created in {@link #_enableGroupingOnResize}. * * @readonly */ resizeObserver = null; /** * A cached value of the horizontal padding style used by {@link #_updateGrouping} * to manage the {@link module:ui/toolbar/toolbarview~ToolbarView#items} that do not fit into * a single toolbar line. This value can be reused between updates because it is unlikely that * the padding will change and re–using `Window.getComputedStyle()` is expensive. * * @readonly */ 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 */ shouldUpdateGroupingOnNextResize = false; /** * Toolbar element. * * @readonly */ viewElement; /** * Creates an instance of the {@link module:ui/toolbar/toolbarview~DynamicGrouping} toolbar * behavior. * * @param view An instance of the toolbar that this behavior is added to. */ constructor(view) { this.view = view; this.viewChildren = view.children; this.viewFocusables = view.focusables; this.viewItemsView = view.itemsView; this.viewFocusTracker = view.focusTracker; this.viewLocale = view.locale; this.view.isGrouping = true; this.ungroupedItems = view.createCollection(); this.groupedItems = view.createCollection(); this.groupedItemsDropdown = this._createGroupedItemsDropdown(); // Only those items that were not grouped are visible to the user. view.itemsView.children.bindTo(this.ungroupedItems).using(item => item); // Make sure all #items visible in the main space of the toolbar are "focuscyclable". this.ungroupedItems.on('change', this._updateFocusCyclableItems.bind(this)); // Make sure the #groupedItemsDropdown is also included in cycling when it appears. view.children.on('change', this._updateFocusCyclableItems.bind(this)); // ToolbarView#items is dynamic. When an item is added or removed, it should be automatically // represented in either grouped or ungrouped items at the right index. // In other words #items == concat( #ungroupedItems, #groupedItems ) // (in length and order). view.items.on('change', (evt, changeData) => { const index = changeData.index; const added = Array.from(changeData.added); // Removing. for (const removedItem of changeData.removed) { if (index >= this.ungroupedItems.length) { this.groupedItems.remove(removedItem); } else { this.ungroupedItems.remove(removedItem); } } // Adding. for (let currentIndex = index; currentIndex < index + added.length; currentIndex++) { const addedItem = added[currentIndex - index]; if (currentIndex > this.ungroupedItems.length) { this.groupedItems.add(addedItem, currentIndex - this.ungroupedItems.length); } else { this.ungroupedItems.add(addedItem, currentIndex); } } // When new ungrouped items join in and land in #ungroupedItems, there's a chance it causes // the toolbar to overflow. // Consequently if removed from grouped or ungrouped items, there is a chance // some new space is available and we could do some ungrouping. this._updateGrouping(); }); } /** * Enables dynamic items grouping based on the dimensions of the toolbar. * * @param view An instance of the toolbar that this behavior is added to. */ render(view) { this.viewElement = view.element; this._enableGroupingOnResize(); this._enableGroupingOnMaxWidthChange(view); } /** * Cleans up the internals used by this behavior. */ destroy() { // The dropdown may not be in ToolbarView#children at the moment of toolbar destruction // so let's make sure it's actually destroyed along with the toolbar. this.groupedItemsDropdown.destroy(); // Do not try to remove the same elements if they are already removed. if (this.viewChildren.length > 1) { this.viewChildren.remove(this.groupedItemsDropdown); this.viewChildren.remove(this.viewChildren.last); } this.resizeObserver.destroy(); } /** * Re-adds all items to the toolbar. Use when the toolbar is re-rendered and the items grouping is lost. */ refreshItems() { const view = this.view; if (view.items.length) { for (let currentIndex = 0; currentIndex < view.items.length; currentIndex++) { const item = [...view.items][currentIndex]; this.ungroupedItems.add(item, currentIndex); } this._updateGrouping(); } } /** * When called, it will check if any of the {@link #ungroupedItems} do not fit into a single row of the toolbar, * and it will move them to the {@link #groupedItems} when it happens. * * At the same time, it will also check if there is enough space in the toolbar for the first of the * {@link #groupedItems} to be returned back to {@link #ungroupedItems} and still fit into a single row * without the toolbar wrapping. */ _updateGrouping() { // 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. 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 (!isVisible(this.viewElement)) { this.shouldUpdateGroupingOnNextResize = true; return; } // Remember how many items were initially grouped so at the it is possible to figure out if the number // of grouped items has changed. If the number has changed, geometry of the toolbar has also changed. const initialGroupedItemsCount = this.groupedItems.length; let wereItemsGrouped; // Group #items as long as some wrap to the next row. This will happen, for instance, // when the toolbar is getting narrow and there is not enough space to display all items in // a single row. while (this._areItemsOverflowing) { this._groupLastItem(); wereItemsGrouped = true; } // If none were grouped now but there were some items already grouped before, // then, what the hell, maybe let's see if some of them can be ungrouped. This happens when, // for instance, the toolbar is stretching and there's more space in it than before. if (!wereItemsGrouped && this.groupedItems.length) { // Ungroup items as long as none are overflowing or there are none to ungroup left. while (this.groupedItems.length && !this._areItemsOverflowing) { this._ungroupFirstItem(); } // If the ungrouping ended up with some item wrapping to the next row, // put it back to the group toolbar ("undo the last ungroup"). We don't know whether // an item will wrap or not until we ungroup it (that's a DOM/CSS thing) so this // clean–up is vital for the algorithm. if (this._areItemsOverflowing) { this._groupLastItem(); } } if (this.groupedItems.length !== initialGroupedItemsCount) { this.view.fire('groupedItemsUpdate'); } } /** * Returns `true` when {@link module:ui/toolbar/toolbarview~ToolbarView#element} children visually overflow, * for instance if the toolbar is narrower than its members. Returns `false` otherwise. */ get _areItemsOverflowing() { // An empty toolbar cannot overflow. if (!this.ungroupedItems.length) { return false; } const element = this.viewElement; const uiLanguageDirection = this.viewLocale.uiLanguageDirection; const lastChildRect = new Rect(element.lastChild); const toolbarRect = new Rect(element); if (!this.cachedPadding) { const computedStyle = global.window.getComputedStyle(element); const paddingProperty = uiLanguageDirection === 'ltr' ? 'paddingRight' : 'paddingLeft'; // parseInt() is essential because of quirky floating point numbers logic and DOM. // If the padding turned out too big because of that, the grouped items dropdown would // always look (from the Rect perspective) like it overflows (while it's not). this.cachedPadding = Number.parseInt(computedStyle[paddingProperty]); } if (uiLanguageDirection === 'ltr') { return lastChildRect.right > toolbarRect.right - this.cachedPadding; } else { return lastChildRect.left < toolbarRect.left + this.cachedPadding; } } /** * Enables the functionality that prevents {@link #ungroupedItems} from overflowing (wrapping to the next row) * upon resize when there is little space available. Instead, the toolbar items are moved to the * {@link #groupedItems} collection and displayed in a dropdown at the end of the row (which has its own nested toolbar). * * When called, the toolbar will automatically analyze the location of its {@link #ungroupedItems} and "group" * them in the dropdown if necessary. It will also observe the browser window for size changes in * the future and respond to them by grouping more items or reverting already grouped back, depending * on the visual space available. */ _enableGroupingOnResize() { let previousWidth; // TODO: Consider debounce. this.resizeObserver = new ResizeObserver(this.viewElement, entry => { if (!previousWidth || previousWidth !== entry.contentRect.width || this.shouldUpdateGroupingOnNextResize) { this.shouldUpdateGroupingOnNextResize = false; this._updateGrouping(); previousWidth = entry.contentRect.width; } }); this._updateGrouping(); } /** * Enables the grouping functionality, just like {@link #_enableGroupingOnResize} but the difference is that * it listens to the changes of {@link module:ui/toolbar/toolbarview~ToolbarView#maxWidth} instead. */ _enableGroupingOnMaxWidthChange(view) { view.on('change:maxWidth', () => { this._updateGrouping(); }); } /** * When called, it will remove the last item from {@link #ungroupedItems} and move it back * to the {@link #groupedItems} collection. * * The opposite of {@link #_ungroupFirstItem}. */ _groupLastItem() { if (!this.groupedItems.length) { this.viewChildren.add(new ToolbarSeparatorView()); this.viewChildren.add(this.groupedItemsDropdown); this.viewFocusTracker.add(this.groupedItemsDropdown.element); } this.groupedItems.add(this.ungroupedItems.remove(this.ungroupedItems.last), 0); } /** * Moves the very first item belonging to {@link #groupedItems} back * to the {@link #ungroupedItems} collection. * * The opposite of {@link #_groupLastItem}. */ _ungroupFirstItem() { this.ungroupedItems.add(this.groupedItems.remove(this.groupedItems.first)); if (!this.groupedItems.length) { this.viewChildren.remove(this.groupedItemsDropdown); this.viewChildren.remove(this.viewChildren.last); this.viewFocusTracker.remove(this.groupedItemsDropdown.element); } } /** * Creates the {@link #groupedItemsDropdown} that hosts the members of the {@link #groupedItems} * collection when there is not enough space in the toolbar to display all items in a single row. */ _createGroupedItemsDropdown() { const locale = this.viewLocale; const t = locale.t; const dropdown = createDropdown(locale); dropdown.class = 'ck-toolbar__grouped-dropdown'; // Make sure the dropdown never sticks out to the left/right. It should be under the main toolbar. // (https://github.com/ckeditor/ckeditor5/issues/5608) dropdown.panelPosition = locale.uiLanguageDirection === 'ltr' ? 'sw' : 'se'; addToolbarToDropdown(dropdown, this.groupedItems); dropdown.buttonView.set({ label: t('Show more items'), tooltip: true, tooltipPosition: locale.uiLanguageDirection === 'rtl' ? 'se' : 'sw', icon: IconThreeVerticalDots }); return dropdown; } /** * Updates the {@link module:ui/toolbar/toolbarview~ToolbarView#focusables focus–cyclable items} * collection so it represents the up–to–date state of the UI from the perspective of the user. * * For instance, the {@link #groupedItemsDropdown} can show up and hide but when it is visible, * it must be subject to focus cycling in the toolbar. * * See the {@link module:ui/toolbar/toolbarview~ToolbarView#focusables collection} documentation * to learn more about the purpose of this method. */ _updateFocusCyclableItems() { this.viewFocusables.clear(); this.ungroupedItems.map(item => { if (isFocusable(item)) { this.viewFocusables.add(item); } }); if (this.groupedItems.length) { this.viewFocusables.add(this.groupedItemsDropdown); } } }