/**
 * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */
/**
 * @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 {
    /**
     * @inheritDoc
     */
    static get pluginName() {
        return 'BlockToolbar';
    }
    /**
     * @inheritDoc
     */
    constructor(editor) {
        super(editor);
        /**
         * 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}.
         */
        this._resizeObserver = null;
        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);
    }
}
