UNPKG

@ckeditor/ckeditor5-ui

Version:

The UI framework and standard UI library of CKEditor 5.

509 lines (508 loc) 23.1 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/block/blocktoolbar */ /* global window */ import { Plugin } from '@ckeditor/ckeditor5-core'; import { getAncestors, global, Rect, ResizeObserver, toUnit } from '@ckeditor/ckeditor5-utils'; import BlockButtonView from './blockbuttonview.js'; import BalloonPanelView from '../../panel/balloon/balloonpanelview.js'; import ToolbarView, { NESTED_TOOLBAR_ICONS } from '../toolbarview.js'; import clickOutsideHandler from '../../bindings/clickoutsidehandler.js'; import normalizeToolbarConfig from '../normalizetoolbarconfig.js'; const toPx = /* #__PURE__ */ toUnit('px'); /** * The block toolbar plugin. * * This plugin provides a button positioned next to the block of content where the selection is anchored. * Upon clicking the button, a dropdown providing access to editor features shows up, as configured in * {@link module:core/editor/editorconfig~EditorConfig#blockToolbar}. * * By default, the button is displayed next to all elements marked in {@link module:engine/model/schema~Schema} * as `$block` for which the toolbar provides at least one option. * * By default, the button is attached so its right boundary is touching the * {@link module:engine/view/editableelement~EditableElement}: * * ``` * __ | * | || This is a block of content that the * ¯¯ | button is attached to. This is a * | block of content that the button is * | attached to. * ``` * * The position of the button can be adjusted using the CSS `transform` property: * * ```css * .ck-block-toolbar-button { * transform: translateX( -10px ); * } * ``` * * ``` * __ | * | | | This is a block of content that the * ¯¯ | button is attached to. This is a * | block of content that the button is * | attached to. * ``` * * **Note**: If you plan to run the editor in a right–to–left (RTL) language, keep in mind the button * will be attached to the **right** boundary of the editable area. In that case, make sure the * CSS position adjustment works properly by adding the following styles: * * ```css * .ck[dir="rtl"] .ck-block-toolbar-button { * transform: translateX( 10px ); * } * ``` */ export default class BlockToolbar extends Plugin { /** * The toolbar view. */ toolbarView; /** * The balloon panel view, containing the {@link #toolbarView}. */ panelView; /** * The button view that opens the {@link #toolbarView}. */ buttonView; /** * 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}. */ _resizeObserver = null; /** * A cached and normalized `config.blockToolbar` object. */ _blockToolbarConfig; /** * @inheritDoc */ static get pluginName() { return 'BlockToolbar'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ constructor(editor) { super(editor); this._blockToolbarConfig = normalizeToolbarConfig(this.editor.config.get('blockToolbar')); this.toolbarView = this._createToolbarView(); this.panelView = this._createPanelView(); this.buttonView = this._createButtonView(); // Close the #panelView upon clicking outside of the plugin UI. clickOutsideHandler({ emitter: this.panelView, contextElements: [this.panelView.element, this.buttonView.element], activator: () => this.panelView.isVisible, callback: () => this._hidePanel() }); } /** * @inheritDoc */ init() { const editor = this.editor; const t = editor.t; const editBlockText = t('Click to edit block'); const dragToMoveText = t('Drag to move'); const editBlockLabel = t('Edit block'); const isDragDropBlockToolbarPluginLoaded = editor.plugins.has('DragDropBlockToolbar'); const label = isDragDropBlockToolbarPluginLoaded ? `${editBlockText}\n${dragToMoveText}` : editBlockLabel; this.buttonView.label = label; if (isDragDropBlockToolbarPluginLoaded) { this.buttonView.element.dataset.ckeTooltipClass = 'ck-tooltip_multi-line'; } // Hides panel on a direct selection change. this.listenTo(editor.model.document.selection, 'change:range', (evt, data) => { if (data.directChange) { this._hidePanel(); } }); this.listenTo(editor.ui, 'update', () => this._updateButton()); // `low` priority is used because of https://github.com/ckeditor/ckeditor5-core/issues/133. this.listenTo(editor, 'change:isReadOnly', () => this._updateButton(), { priority: 'low' }); this.listenTo(editor.ui.focusTracker, 'change:isFocused', () => this._updateButton()); // Reposition button on resize. this.listenTo(this.buttonView, 'change:isVisible', (evt, name, isVisible) => { if (isVisible) { // Keep correct position of button and panel on window#resize. this.buttonView.listenTo(window, 'resize', () => this._updateButton()); } else { // Stop repositioning button when is hidden. this.buttonView.stopListening(window, 'resize'); // Hide the panel when the button disappears. this._hidePanel(); } }); // Reposition button on scroll. this._repositionButtonOnScroll(); // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. editor.ui.addToolbar(this.toolbarView, { beforeFocus: () => this._showPanel(), afterBlur: () => this._hidePanel() }); // Fills the toolbar with its items based on the configuration. // This needs to be done after all plugins are ready. editor.ui.once('ready', () => { this.toolbarView.fillFromConfig(this._blockToolbarConfig, this.editor.ui.componentFactory); // Hide panel before executing each button in the panel. for (const item of this.toolbarView.items) { item.on('execute', () => this._hidePanel(true), { priority: 'high' }); } }); } /** * @inheritDoc */ destroy() { super.destroy(); // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341). this.panelView.destroy(); this.buttonView.destroy(); this.toolbarView.destroy(); if (this._resizeObserver) { this._resizeObserver.destroy(); } } /** * Creates the {@link #toolbarView}. */ _createToolbarView() { const t = this.editor.locale.t; const shouldGroupWhenFull = !this._blockToolbarConfig.shouldNotGroupWhenFull; const toolbarView = new ToolbarView(this.editor.locale, { shouldGroupWhenFull, isFloating: true }); toolbarView.ariaLabel = t('Editor block content toolbar'); return toolbarView; } /** * Creates the {@link #panelView}. */ _createPanelView() { const editor = this.editor; const panelView = new BalloonPanelView(editor.locale); panelView.content.add(this.toolbarView); panelView.class = 'ck-toolbar-container'; editor.ui.view.body.add(panelView); // Close #panelView on `Esc` press. this.toolbarView.keystrokes.set('Esc', (evt, cancel) => { this._hidePanel(true); cancel(); }); return panelView; } /** * Creates the {@link #buttonView}. */ _createButtonView() { const editor = this.editor; const t = editor.t; const buttonView = new BlockButtonView(editor.locale); const iconFromConfig = this._blockToolbarConfig.icon; const icon = NESTED_TOOLBAR_ICONS[iconFromConfig] || iconFromConfig || NESTED_TOOLBAR_ICONS.dragIndicator; buttonView.set({ label: t('Edit block'), icon, withText: false }); // Bind the panelView observable properties to the buttonView. buttonView.bind('isOn').to(this.panelView, 'isVisible'); buttonView.bind('tooltip').to(this.panelView, 'isVisible', isVisible => !isVisible); // Toggle the panelView upon buttonView#execute. this.listenTo(buttonView, 'execute', () => { if (!this.panelView.isVisible) { this._showPanel(); } else { this._hidePanel(true); } }); // Hide the panelView when the buttonView is disabled. `isEnabled` flag might be changed when // user scrolls the viewport and the button is no longer visible. In such case, the panel should be hidden // otherwise it will be displayed in the wrong place. this.listenTo(buttonView, 'change:isEnabled', (evt, name, isEnabled) => { if (!isEnabled && this.panelView.isVisible) { this._hidePanel(false); } }); editor.ui.view.body.add(buttonView); return buttonView; } /** * Shows or hides the button. * When all the conditions for displaying the button are matched, it shows the button. Hides otherwise. */ _updateButton() { const editor = this.editor; const model = editor.model; const view = editor.editing.view; // Hides the button when the editor is not focused. if (!editor.ui.focusTracker.isFocused) { this._hideButton(); return; } // Hides the button when the selection is in non-editable place. if (!editor.model.canEditAt(editor.model.document.selection)) { this._hideButton(); return; } // Get the first selected block, button will be attached to this element. const modelTarget = Array.from(model.document.selection.getSelectedBlocks())[0]; // Hides the button when there is no enabled item in toolbar for the current block element. if (!modelTarget || Array.from(this.toolbarView.items).every((item) => !item.isEnabled)) { this._hideButton(); return; } // Get DOM target element. const domTarget = view.domConverter.mapViewToDom(editor.editing.mapper.toViewElement(modelTarget)); // Show block button. this.buttonView.isVisible = true; // Make sure that the block toolbar panel is resized properly. this._setupToolbarResize(); // Attach block button to target DOM element. this._attachButtonToElement(domTarget); // When panel is opened then refresh it position to be properly aligned with block button. if (this.panelView.isVisible) { this._showPanel(); } } /** * Hides the button. */ _hideButton() { this.buttonView.isVisible = false; } /** * Shows the {@link #toolbarView} attached to the {@link #buttonView}. * If the toolbar is already visible, then it simply repositions it. */ _showPanel() { // Usually, the only way to show the toolbar is by pressing the block button. It makes it impossible for // the toolbar to show up when the button is invisible (feature does not make sense for the selection then). // The toolbar navigation using Alt+F10 does not access the button but shows the panel directly using this method. // So we need to check whether this is possible first. if (!this.buttonView.isVisible) { return; } 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(); const editableElement = this._getSelectedEditableElement(); this.toolbarView.maxWidth = this._getToolbarMaxWidth(editableElement); this.panelView.pin({ target: this.buttonView.element, limiter: editableElement }); if (!wasVisible) { this.toolbarView.items.get(0).focus(); } } /** * Returns currently selected editable, based on the model selection. */ _getSelectedEditableElement() { const selectedModelRootName = this.editor.model.document.selection.getFirstRange().root.rootName; return this.editor.ui.getEditableElement(selectedModelRootName); } /** * Hides the {@link #toolbarView}. * * @param focusEditable When `true`, the editable will be focused after hiding the panel. */ _hidePanel(focusEditable) { this.panelView.isVisible = false; if (focusEditable) { this.editor.editing.view.focus(); } } /** * Repositions the button on scroll. */ _repositionButtonOnScroll() { const { buttonView } = this; let pendingAnimationFrame = false; // Reposition the button on scroll, but do it only once per animation frame to avoid performance issues. const repositionOnScroll = (evt, domEvt) => { if (pendingAnimationFrame) { return; } // It makes no sense to reposition the button when the user scrolls the dropdown or any other // nested scrollable element. The button should be repositioned only when the user scrolls the // editable or any other scrollable parent of the editable. Leaving it as it is buggy on Chrome // where scrolling nested scrollables is not properly handled. // See more: https://github.com/ckeditor/ckeditor5/issues/17067 const editableElement = this._getSelectedEditableElement(); if (domEvt.target !== global.document && !getAncestors(editableElement).includes(domEvt.target)) { return; } pendingAnimationFrame = true; global.window.requestAnimationFrame(() => { this._updateButton(); pendingAnimationFrame = false; }); }; // Watch scroll event only when the button is visible, it prevents attaching the scroll event listener // to the document when the button is not visible. buttonView.on('change:isVisible', (evt, name, isVisible) => { if (isVisible) { buttonView.listenTo(global.document, 'scroll', repositionOnScroll, { useCapture: true, usePassive: true }); } else { buttonView.stopListening(global.document, 'scroll', repositionOnScroll); } }); } /** * Attaches the {@link #buttonView} to the target block of content. * * @param targetElement Target element. */ _attachButtonToElement(targetElement) { const buttonElement = this.buttonView.element; const editableElement = this._getSelectedEditableElement(); const contentStyles = window.getComputedStyle(targetElement); const editableRect = new Rect(editableElement); const contentPaddingTop = parseInt(contentStyles.paddingTop, 10); // When line height is not an integer then treat it as "normal". // MDN says that 'normal' == ~1.2 on desktop browsers. const contentLineHeight = parseInt(contentStyles.lineHeight, 10) || parseInt(contentStyles.fontSize, 10) * 1.2; const buttonRect = new Rect(buttonElement); const contentRect = new Rect(targetElement); let positionLeft; if (this.editor.locale.uiLanguageDirection === 'ltr') { positionLeft = editableRect.left - buttonRect.width; } else { positionLeft = editableRect.right; } const positionTop = contentRect.top + contentPaddingTop + (contentLineHeight - buttonRect.height) / 2; buttonRect.moveTo(positionLeft, positionTop); const absoluteButtonRect = buttonRect.toAbsoluteRect(); this.buttonView.top = absoluteButtonRect.top; this.buttonView.left = absoluteButtonRect.left; this._clipButtonToViewport(this.buttonView, editableElement); } /** * Clips the button element to the viewport of the editable element. * * * If the button overflows the editable viewport, it is clipped to make it look like it's cut off by the editable scrollable region. * * If the button is fully hidden by the top of the editable, it is not clickable but still visible in the DOM. * * @param buttonView The button view to clip. * @param editableElement The editable element whose viewport is used for clipping. */ _clipButtonToViewport(buttonView, editableElement) { const absoluteButtonRect = new Rect(buttonView.element); const scrollViewportRect = new Rect(editableElement).getVisible(); // Sets polygon clip path for the button element, if there is no argument provided, the clip path is removed. const setButtonClipping = (...paths) => { buttonView.element.style.clipPath = paths.length ? `polygon(${paths.join(',')})` : ''; }; // Hide the button if it's fully hidden by the top of the editable. // Note that the button is still visible in the DOM, but it's not clickable. It's because we don't // want to hide the button completely, as there are plenty of `isVisible` watchers which toggles // the button scroll listeners. const markAsHidden = (isHidden) => { buttonView.isEnabled = !isHidden; buttonView.element.style.pointerEvents = isHidden ? 'none' : ''; }; if (scrollViewportRect && scrollViewportRect.bottom < absoluteButtonRect.bottom) { // Calculate the delta between the button bottom and the editable bottom, and clip the button // to make it look like it's cut off by the editable scrollable region. const delta = Math.min(absoluteButtonRect.height, absoluteButtonRect.bottom - scrollViewportRect.bottom); markAsHidden(delta >= absoluteButtonRect.height); setButtonClipping('0 0', '100% 0', `100% calc(100% - ${toPx(delta)})`, `0 calc(100% - ${toPx(delta)}`); } else if (scrollViewportRect && scrollViewportRect.top > absoluteButtonRect.top) { // Calculate the delta between the button top and the editable top, and clip the button // to make it look like it's cut off by the editable scrollable region. const delta = Math.min(absoluteButtonRect.height, scrollViewportRect.top - absoluteButtonRect.top); markAsHidden(delta >= absoluteButtonRect.height); setButtonClipping(`0 ${toPx(delta)}`, `100% ${toPx(delta)}`, '100% 100%', '0 100%'); } else { // Reset the clip path if button is fully visible. markAsHidden(false); setButtonClipping(); } } /** * Creates a resize observer that observes selected editable and resizes the toolbar panel accordingly. */ _setupToolbarResize() { const editableElement = this._getSelectedEditableElement(); // Do this only if the automatic grouping is turned on. if (!this._blockToolbarConfig.shouldNotGroupWhenFull) { // If resize observer is attached to a different editable than currently selected editable, re-attach it. if (this._resizeObserver && this._resizeObserver.element !== editableElement) { this._resizeObserver.destroy(); this._resizeObserver = null; } if (!this._resizeObserver) { this._resizeObserver = new ResizeObserver(editableElement, () => { this.toolbarView.maxWidth = this._getToolbarMaxWidth(editableElement); }); } } } /** * Gets the {@link #toolbarView} max-width, based on given `editableElement` width plus the distance between the farthest * edge of the {@link #buttonView} and the editable. * * @returns A maximum width that toolbar can have, in pixels. */ _getToolbarMaxWidth(editableElement) { 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); } }