UNPKG

@ckeditor/ckeditor5-ui

Version:

The UI framework and standard UI library of CKEditor 5.

352 lines (351 loc) • 15 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/balloon/balloontoolbar */ import ContextualBalloon from '../../panel/balloon/contextualballoon.js'; import ToolbarView from '../toolbarview.js'; import BalloonPanelView from '../../panel/balloon/balloonpanelview.js'; import normalizeToolbarConfig from '../normalizetoolbarconfig.js'; import { Plugin } from '@ckeditor/ckeditor5-core'; import { FocusTracker, Rect, ResizeObserver, env, global, toUnit } from '@ckeditor/ckeditor5-utils'; import { Observer } from '@ckeditor/ckeditor5-engine'; import { debounce } from 'es-toolkit/compat'; const toPx = /* #__PURE__ */ toUnit('px'); /** * The contextual toolbar. * * It uses the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}. */ export default class BalloonToolbar extends Plugin { /** * The toolbar view displayed in the balloon. */ toolbarView; /** * Tracks the focus of the {@link module:ui/editorui/editorui~EditorUI#getEditableElement editable element} * and the {@link #toolbarView}. When both are blurred then the toolbar should hide. */ focusTracker; /** * A cached and normalized `config.balloonToolbar` object. */ _balloonConfig; /** * 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#balloonToolbar configuration}. * * **Note:** Created in {@link #init}. */ _resizeObserver = null; /** * The contextual balloon plugin instance. */ _balloon; /** * Fires `_selectionChangeDebounced` event using `es-toolkit#debounce`. * * This event is an internal plugin event which is fired 200 ms after model selection last change. * This is to makes easy test debounced action without need to use `setTimeout`. * * This function is stored as a plugin property to make possible to cancel * trailing debounced invocation on destroy. */ _fireSelectionChangeDebounced; /** * @inheritDoc */ static get pluginName() { return 'BalloonToolbar'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ static get requires() { return [ContextualBalloon]; } /** * @inheritDoc */ constructor(editor) { super(editor); this._balloonConfig = normalizeToolbarConfig(editor.config.get('balloonToolbar')); this.toolbarView = this._createToolbarView(); this.focusTracker = new FocusTracker(); // Track focusable elements in the toolbar and the editable elements. this._trackFocusableEditableElements(); this.focusTracker.add(this.toolbarView); // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. editor.ui.addToolbar(this.toolbarView, { beforeFocus: () => this.show(true), afterBlur: () => this.hide(), isContextual: true }); this._balloon = editor.plugins.get(ContextualBalloon); this._fireSelectionChangeDebounced = debounce(() => this.fire('_selectionChangeDebounced'), 200); // The appearance of the BalloonToolbar method is event–driven. // It is possible to stop the #show event and this prevent the toolbar from showing up. this.decorate('show'); } /** * @inheritDoc */ init() { const editor = this.editor; const selection = editor.model.document.selection; // Show/hide the toolbar on editable focus/blur. this.listenTo(this.focusTracker, 'change:isFocused', (evt, name, isFocused) => { const isToolbarVisible = this._balloon.visibleView === this.toolbarView; if (!isFocused && isToolbarVisible) { this.hide(); } else if (isFocused) { this.show(); } }); // Hide the toolbar when the selection is changed by a direct change or has changed to collapsed. this.listenTo(selection, 'change:range', (evt, data) => { if (data.directChange || selection.isCollapsed) { this.hide(); } // Fire internal `_selectionChangeDebounced` event to use it for showing // the toolbar after the selection stops changing. this._fireSelectionChangeDebounced(); }); // Show the toolbar when the selection stops changing. this.listenTo(this, '_selectionChangeDebounced', () => { if (this.editor.editing.view.document.isFocused) { this.show(); } }); if (!this._balloonConfig.shouldNotGroupWhenFull) { this.listenTo(editor, 'ready', () => { const editableElement = editor.ui.view.editable.element; // Set #toolbarView's max-width on the initialization and update it on the editable resize. this._resizeObserver = new ResizeObserver(editableElement, entry => { // The max-width equals 90% of the editable's width for the best user experience. // The value keeps the balloon very close to the boundaries of the editable and limits the cases // when the balloon juts out from the editable element it belongs to. this.toolbarView.maxWidth = toPx(entry.contentRect.width * .9); }); }); } // Listen to the toolbar view and whenever it changes its geometry due to some items being // grouped or ungrouped, update the position of the balloon because a shorter/longer toolbar // means the balloon could be pointing at the wrong place. Once updated, the balloon will point // at the right selection in the content again. // https://github.com/ckeditor/ckeditor5/issues/6444 this.listenTo(this.toolbarView, 'groupedItemsUpdate', () => { this._updatePosition(); }); // Creates toolbar components based on given configuration. // This needs to be done when all plugins are ready. editor.ui.once('ready', () => { this.toolbarView.fillFromConfig(this._balloonConfig, this.editor.ui.componentFactory); }); } /** * Creates the toolbar view instance. */ _createToolbarView() { const t = this.editor.locale.t; const shouldGroupWhenFull = !this._balloonConfig.shouldNotGroupWhenFull; const toolbarView = new ToolbarView(this.editor.locale, { shouldGroupWhenFull, isFloating: true }); toolbarView.ariaLabel = t('Editor contextual toolbar'); toolbarView.render(); return toolbarView; } /** * Shows the toolbar and attaches it to the selection. * * Fires {@link #event:show} event which can be stopped to prevent the toolbar from showing up. * * @param showForCollapsedSelection When set `true`, the toolbar will show despite collapsed selection in the * editing view. */ show(showForCollapsedSelection = false) { const editor = this.editor; const selection = editor.model.document.selection; const schema = editor.model.schema; // Do not add the toolbar to the balloon stack twice. if (this._balloon.hasView(this.toolbarView)) { return; } // Do not show the toolbar when the selection is collapsed. if (selection.isCollapsed && !showForCollapsedSelection) { return; } // Do not show the toolbar when there is more than one range in the selection and they fully contain selectable elements. // See https://github.com/ckeditor/ckeditor5/issues/6443. if (selectionContainsOnlyMultipleSelectables(selection, schema)) { return; } // Do not show the toolbar when all components inside are disabled // see https://github.com/ckeditor/ckeditor5-ui/issues/269. if (Array.from(this.toolbarView.items).every((item) => item.isEnabled !== undefined && !item.isEnabled)) { return; } // Update the toolbar position when the editor ui should be refreshed. this.listenTo(this.editor.ui, 'update', () => { this._updatePosition(); }); // Add the toolbar to the common editor contextual balloon. this._balloon.add({ view: this.toolbarView, position: this._getBalloonPositionData(), balloonClassName: 'ck-toolbar-container' }); } /** * Hides the toolbar. */ hide() { if (this._balloon.hasView(this.toolbarView)) { this.stopListening(this.editor.ui, 'update'); this._balloon.remove(this.toolbarView); } } /** * Add or remove editable elements to the focus tracker. It watches added and removed roots * and adds or removes their editable elements to the focus tracker. */ _trackFocusableEditableElements() { const { editor, focusTracker } = this; const { editing } = editor; editing.view.addObserver(class TrackEditableElements extends Observer { /** * @inheritDoc */ observe(domElement) { focusTracker.add(domElement); } /** * @inheritDoc */ stopObserving(domElement) { focusTracker.remove(domElement); } }); } /** * Returns positioning options for the {@link #_balloon}. They control the way balloon is attached * to the selection. */ _getBalloonPositionData() { const editor = this.editor; const view = editor.editing.view; const viewDocument = view.document; const viewSelection = viewDocument.selection; // Get direction of the selection. const isBackward = viewDocument.selection.isBackward; return { // Because the target for BalloonPanelView is a Rect (not DOMRange), it's geometry will stay fixed // as the window scrolls. To let the BalloonPanelView follow such Rect, is must be continuously // computed and hence, the target is defined as a function instead of a static value. // https://github.com/ckeditor/ckeditor5-ui/issues/195 target: () => { const range = isBackward ? viewSelection.getFirstRange() : viewSelection.getLastRange(); const rangeRects = Rect.getDomRangeRects(view.domConverter.viewRangeToDom(range)); // Select the proper range rect depending on the direction of the selection. if (isBackward) { return rangeRects[0]; } else { // Ditch the zero-width "orphan" rect in the next line for the forward selection if there's // another one preceding it. It is not rendered as a selection by the web browser anyway. // https://github.com/ckeditor/ckeditor5-ui/issues/308 if (rangeRects.length > 1 && rangeRects[rangeRects.length - 1].width === 0) { rangeRects.pop(); } return rangeRects[rangeRects.length - 1]; } }, positions: this._getBalloonPositions(isBackward) }; } /** * Updates the position of the {@link #_balloon} to make up for changes: * * * in the geometry of the selection it is attached to (e.g. the selection moved in the viewport or expanded or shrunk), * * or the geometry of the balloon toolbar itself (e.g. the toolbar has grouped or ungrouped some items and it is shorter or longer). */ _updatePosition() { this._balloon.updatePosition(this._getBalloonPositionData()); } /** * @inheritDoc */ destroy() { super.destroy(); this.stopListening(); this._fireSelectionChangeDebounced.cancel(); this.toolbarView.destroy(); this.focusTracker.destroy(); if (this._resizeObserver) { this._resizeObserver.destroy(); } } /** * Returns toolbar positions for the given direction of the selection. */ _getBalloonPositions(isBackward) { const isSafariIniOS = env.isSafari && env.isiOS; // https://github.com/ckeditor/ckeditor5/issues/7707 const positions = isSafariIniOS ? BalloonPanelView.generatePositions({ // 20px when zoomed out. Less then 20px when zoomed in; the "radius" of the native selection handle gets // smaller as the user zooms in. No less than the default v-offset, though. heightOffset: Math.max(BalloonPanelView.arrowHeightOffset, Math.round(20 / global.window.visualViewport.scale)) }) : BalloonPanelView.defaultPositions; return isBackward ? [ positions.northWestArrowSouth, positions.northWestArrowSouthWest, positions.northWestArrowSouthEast, positions.northWestArrowSouthMiddleEast, positions.northWestArrowSouthMiddleWest, positions.southWestArrowNorth, positions.southWestArrowNorthWest, positions.southWestArrowNorthEast, positions.southWestArrowNorthMiddleWest, positions.southWestArrowNorthMiddleEast ] : [ positions.southEastArrowNorth, positions.southEastArrowNorthEast, positions.southEastArrowNorthWest, positions.southEastArrowNorthMiddleEast, positions.southEastArrowNorthMiddleWest, positions.northEastArrowSouth, positions.northEastArrowSouthEast, positions.northEastArrowSouthWest, positions.northEastArrowSouthMiddleEast, positions.northEastArrowSouthMiddleWest ]; } } /** * Returns "true" when the selection has multiple ranges and each range contains a selectable element * and nothing else. */ function selectionContainsOnlyMultipleSelectables(selection, schema) { // It doesn't contain multiple objects if there is only one range. if (selection.rangeCount === 1) { return false; } return [...selection.getRanges()].every(range => { const element = range.getContainedElement(); return element && schema.isSelectable(element); }); }