/**
 * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */
/**
 * @module ui/panel/balloon/contextualballoon
 */
import { Plugin } from '@ckeditor/ckeditor5-core';
import BalloonPanelView from './balloonpanelview';
import View from '../../view';
import ButtonView from '../../button/buttonview';
import { CKEditorError, FocusTracker, Rect, toUnit } from '@ckeditor/ckeditor5-utils';
import prevIcon from '../../../theme/icons/previous-arrow.svg';
import nextIcon from '../../../theme/icons/next-arrow.svg';
import '../../../theme/components/panel/balloonrotator.css';
import '../../../theme/components/panel/fakepanel.css';
const toPx = toUnit('px');
/**
 * Provides the common contextual balloon for the editor.
 *
 * The role of this plugin is to unify the contextual balloons logic, simplify views management and help
 * avoid the unnecessary complexity of handling multiple {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
 * instances in the editor.
 *
 * This plugin allows for creating single or multiple panel stacks.
 *
 * Each stack may have multiple views, with the one on the top being visible. When the visible view is removed from the stack,
 * the previous view becomes visible.
 *
 * It might be useful to implement nested navigation in a balloon. For instance, a toolbar view may contain a link button.
 * When you click it, a link view (which lets you set the URL) is created and put on top of the toolbar view, so the link panel
 * is displayed. When you finish editing the link and close (remove) the link view, the toolbar view is visible again.
 *
 * However, there are cases when there are multiple independent balloons to be displayed, for instance, if the selection
 * is inside two inline comments at the same time. For such cases, you can create two independent panel stacks.
 * The contextual balloon plugin will create a navigation bar to let the users switch between these panel stacks using the "Next"
 * and "Previous" buttons.
 *
 * If there are no views in the current stack, the balloon panel will try to switch to the next stack. If there are no
 * panels in any stack, the balloon panel will be hidden.
 *
 * **Note**: To force the balloon panel to show only one view, even if there are other stacks, use the `singleViewMode=true` option
 * when {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon#add adding} a view to a panel.
 *
 * From the implementation point of view, the contextual ballon plugin is reusing a single
 * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView} instance to display multiple contextual balloon
 * panels in the editor. It also creates a special {@link module:ui/panel/balloon/contextualballoon~RotatorView rotator view},
 * used to manage multiple panel stacks. Rotator view is a child of the balloon panel view and the parent of the specific
 * view you want to display. If there is more than one panel stack to be displayed, the rotator view will add a
 * navigation bar. If there is only one stack, the rotator view is transparent (it does not add any UI elements).
 *
 * @extends module:core/plugin~Plugin
 */
export default class ContextualBalloon extends Plugin {
    /**
     * @inheritDoc
     */
    static get pluginName() {
        return 'ContextualBalloon';
    }
    /**
     * @inheritDoc
     */
    constructor(editor) {
        super(editor);
        /**
         * The {@link module:utils/dom/position~Options#limiter position limiter}
         * for the {@link #view balloon}, used when no `limiter` has been passed into {@link #add}
         * or {@link #updatePosition}.
         *
         * By default, a function that obtains the farthest DOM
         * {@link module:engine/view/rooteditableelement~RootEditableElement}
         * of the {@link module:engine/view/document~Document#selection}.
         *
         * @member {module:utils/dom/position~Options#limiter} #positionLimiter
         */
        this.positionLimiter = () => {
            const view = this.editor.editing.view;
            const viewDocument = view.document;
            const editableElement = viewDocument.selection.editableElement;
            if (editableElement) {
                return view.domConverter.mapViewToDom(editableElement.root);
            }
            return null;
        };
        /**
         * The currently visible view or `null` when there are no views in any stack.
         *
         * @readonly
         * @observable
         * @member {module:ui/view~View|null} #visibleView
         */
        this.set('visibleView', null);
        /**
         * The common balloon panel view.
         *
         * @readonly
         * @member {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} #view
         */
        this.view = new BalloonPanelView(editor.locale);
        editor.ui.view.body.add(this.view);
        editor.ui.focusTracker.add(this.view.element);
        /**
         * The map of views and their stacks.
         *
         * @private
         * @type {Map.<module:ui/view~View,Set>}
         */
        this._viewToStack = new Map();
        /**
         * The map of IDs and stacks.
         *
         * @private
         * @type {Map.<String,Set>}
         */
        this._idToStack = new Map();
        /**
         * A total number of all stacks in the balloon.
         *
         * @private
         * @readonly
         * @observable
         * @member {Number} #_numberOfStacks
         */
        this.set('_numberOfStacks', 0);
        /**
         * A flag that controls the single view mode.
         *
         * @private
         * @readonly
         * @observable
         * @member {Boolean} #_singleViewMode
         */
        this.set('_singleViewMode', false);
        /**
         * Rotator view embedded in the contextual balloon.
         * Displays the currently visible view in the balloon and provides navigation for switching stacks.
         *
         * @private
         * @type {module:ui/panel/balloon/contextualballoon~RotatorView}
         */
        this._rotatorView = this._createRotatorView();
        /**
         * Displays fake panels under the balloon panel view when multiple stacks are added to the balloon.
         *
         * @private
         * @type {module:ui/view~View}
         */
        this._fakePanelsView = this._createFakePanelsView();
    }
    /**
     * @inheritDoc
     */
    destroy() {
        super.destroy();
        this.view.destroy();
        this._rotatorView.destroy();
        this._fakePanelsView.destroy();
    }
    /**
     * Returns `true` when the given view is in one of the stacks. Otherwise returns `false`.
     *
     * @param {module:ui/view~View} view
     * @returns {Boolean}
     */
    hasView(view) {
        return Array.from(this._viewToStack.keys()).includes(view);
    }
    /**
     * Adds a new view to the stack and makes it visible if the current stack is visible
     * or it is the first view in the balloon.
     *
     * @param {Object} data The configuration of the view.
     * @param {String} [data.stackId='main'] The ID of the stack that the view is added to.
     * @param {module:ui/view~View} [data.view] The content of the balloon.
     * @param {module:utils/dom/position~Options} [data.position] Positioning options.
     * @param {String} [data.balloonClassName] An additional CSS class added to the {@link #view balloon} when visible.
     * @param {Boolean} [data.withArrow=true] Whether the {@link #view balloon} should be rendered with an arrow.
     * @param {Boolean} [data.singleViewMode=false] Whether the view should be the only visible view even if other stacks were added.
     */
    add(data) {
        if (this.hasView(data.view)) {
            /**
             * Trying to add configuration of the same view more than once.
             *
             * @error contextualballoon-add-view-exist
             */
            throw new CKEditorError('contextualballoon-add-view-exist', [this, data]);
        }
        const stackId = data.stackId || 'main';
        // If new stack is added, creates it and show view from this stack.
        if (!this._idToStack.has(stackId)) {
            this._idToStack.set(stackId, new Map([[data.view, data]]));
            this._viewToStack.set(data.view, this._idToStack.get(stackId));
            this._numberOfStacks = this._idToStack.size;
            if (!this._visibleStack || data.singleViewMode) {
                this.showStack(stackId);
            }
            return;
        }
        const stack = this._idToStack.get(stackId);
        if (data.singleViewMode) {
            this.showStack(stackId);
        }
        // Add new view to the stack.
        stack.set(data.view, data);
        this._viewToStack.set(data.view, stack);
        // And display it if is added to the currently visible stack.
        if (stack === this._visibleStack) {
            this._showView(data);
        }
    }
    /**
     * Removes the given view from the stack. If the removed view was visible,
     * the view preceding it in the stack will become visible instead.
     * When there is no view in the stack, the next stack will be displayed.
     * When there are no more stacks, the balloon will hide.
     *
     * @param {module:ui/view~View} view A view to be removed from the balloon.
     */
    remove(view) {
        if (!this.hasView(view)) {
            /**
             * Trying to remove the configuration of the view not defined in the stack.
             *
             * @error contextualballoon-remove-view-not-exist
             */
            throw new CKEditorError('contextualballoon-remove-view-not-exist', [this, view]);
        }
        const stack = this._viewToStack.get(view);
        if (this._singleViewMode && this.visibleView === view) {
            this._singleViewMode = false;
        }
        // When visible view will be removed we need to show a preceding view or next stack
        // if a view is the only view in the stack.
        if (this.visibleView === view) {
            if (stack.size === 1) {
                if (this._idToStack.size > 1) {
                    this._showNextStack();
                }
                else {
                    this.view.hide();
                    this.visibleView = null;
                    this._rotatorView.hideView();
                }
            }
            else {
                this._showView(Array.from(stack.values())[stack.size - 2]);
            }
        }
        if (stack.size === 1) {
            this._idToStack.delete(this._getStackId(stack));
            this._numberOfStacks = this._idToStack.size;
        }
        else {
            stack.delete(view);
        }
        this._viewToStack.delete(view);
    }
    /**
     * Updates the position of the balloon using the position data of the first visible view in the stack.
     * When new position data is given, the position data of the currently visible view will be updated.
     *
     * @param {module:utils/dom/position~Options} [position] position options.
     */
    updatePosition(position) {
        if (position) {
            this._visibleStack.get(this.visibleView).position = position;
        }
        this.view.pin(this._getBalloonPosition());
        this._fakePanelsView.updatePosition();
    }
    /**
     * Shows the last view from the stack of a given ID.
     *
     * @param {String} id
     */
    showStack(id) {
        this.visibleStack = id;
        const stack = this._idToStack.get(id);
        if (!stack) {
            /**
             * Trying to show a stack that does not exist.
             *
             * @error contextualballoon-showstack-stack-not-exist
             */
            throw new CKEditorError('contextualballoon-showstack-stack-not-exist', this);
        }
        if (this._visibleStack === stack) {
            return;
        }
        this._showView(Array.from(stack.values()).pop());
    }
    /**
     * Returns the stack of the currently visible view.
     *
     * @private
     * @type {Set}
     */
    get _visibleStack() {
        return this._viewToStack.get(this.visibleView);
    }
    /**
     * Returns the ID of the given stack.
     *
     * @private
     * @param {Set} stack
     * @returns {String}
     */
    _getStackId(stack) {
        const entry = Array.from(this._idToStack.entries()).find(entry => entry[1] === stack);
        return entry[0];
    }
    /**
     * Shows the last view from the next stack.
     *
     * @private
     */
    _showNextStack() {
        const stacks = Array.from(this._idToStack.values());
        let nextIndex = stacks.indexOf(this._visibleStack) + 1;
        if (!stacks[nextIndex]) {
            nextIndex = 0;
        }
        this.showStack(this._getStackId(stacks[nextIndex]));
    }
    /**
     * Shows the last view from the previous stack.
     *
     * @private
     */
    _showPrevStack() {
        const stacks = Array.from(this._idToStack.values());
        let nextIndex = stacks.indexOf(this._visibleStack) - 1;
        if (!stacks[nextIndex]) {
            nextIndex = stacks.length - 1;
        }
        this.showStack(this._getStackId(stacks[nextIndex]));
    }
    /**
     * Creates a rotator view.
     *
     * @private
     * @returns {module:ui/panel/balloon/contextualballoon~RotatorView}
     */
    _createRotatorView() {
        const view = new RotatorView(this.editor.locale);
        const t = this.editor.locale.t;
        this.view.content.add(view);
        // Hide navigation when there is only a one stack & not in single view mode.
        view.bind('isNavigationVisible').to(this, '_numberOfStacks', this, '_singleViewMode', (value, isSingleViewMode) => {
            return !isSingleViewMode && value > 1;
        });
        // Update balloon position after toggling navigation.
        view.on('change:isNavigationVisible', () => (this.updatePosition()), { priority: 'low' });
        // Update stacks counter value.
        view.bind('counter').to(this, 'visibleView', this, '_numberOfStacks', (visibleView, numberOfStacks) => {
            if (numberOfStacks < 2) {
                return '';
            }
            const current = Array.from(this._idToStack.values()).indexOf(this._visibleStack) + 1;
            return t('%0 of %1', [current, numberOfStacks]);
        });
        view.buttonNextView.on('execute', () => {
            // When current view has a focus then move focus to the editable before removing it,
            // otherwise editor will lost focus.
            if (view.focusTracker.isFocused) {
                this.editor.editing.view.focus();
            }
            this._showNextStack();
        });
        view.buttonPrevView.on('execute', () => {
            // When current view has a focus then move focus to the editable before removing it,
            // otherwise editor will lost focus.
            if (view.focusTracker.isFocused) {
                this.editor.editing.view.focus();
            }
            this._showPrevStack();
        });
        return view;
    }
    /**
     * @private
     * @returns {module:ui/view~View}
     */
    _createFakePanelsView() {
        const view = new FakePanelsView(this.editor.locale, this.view);
        view.bind('numberOfPanels').to(this, '_numberOfStacks', this, '_singleViewMode', (number, isSingleViewMode) => {
            const showPanels = !isSingleViewMode && number >= 2;
            return showPanels ? Math.min(number - 1, 2) : 0;
        });
        view.listenTo(this.view, 'change:top', () => view.updatePosition());
        view.listenTo(this.view, 'change:left', () => view.updatePosition());
        this.editor.ui.view.body.add(view);
        return view;
    }
    /**
     * Sets the view as the content of the balloon and attaches the balloon using position
     * options of the first view.
     *
     * @private
     * @param {Object} data Configuration.
     * @param {module:ui/view~View} [data.view] The view to show in the balloon.
     * @param {String} [data.balloonClassName=''] Additional class name which will be added to the {@link #view balloon}.
     * @param {Boolean} [data.withArrow=true] Whether the {@link #view balloon} should be rendered with an arrow.
     */
    _showView({ view, balloonClassName = '', withArrow = true, singleViewMode = false }) {
        this.view.class = balloonClassName;
        this.view.withArrow = withArrow;
        this._rotatorView.showView(view);
        this.visibleView = view;
        this.view.pin(this._getBalloonPosition());
        this._fakePanelsView.updatePosition();
        if (singleViewMode) {
            this._singleViewMode = true;
        }
    }
    /**
     * Returns position options of the last view in the stack.
     * This keeps the balloon in the same position when the view is changed.
     *
     * @private
     * @returns {module:utils/dom/position~Options}
     */
    _getBalloonPosition() {
        let position = Array.from(this._visibleStack.values()).pop().position;
        if (position) {
            // Use the default limiter if none has been specified.
            if (!position.limiter) {
                // Don't modify the original options object.
                position = Object.assign({}, position, {
                    limiter: this.positionLimiter
                });
            }
            // Don't modify the original options object.
            position = Object.assign({}, position, {
                viewportOffsetConfig: this.editor.ui.viewportOffset
            });
        }
        return position;
    }
}
/**
 * Rotator view is a helper class for the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon ContextualBalloon}.
 * It is used for displaying the last view from the current stack and providing navigation buttons for switching stacks.
 * See the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon ContextualBalloon} documentation to learn more.
 *
 * @extends module:ui/view~View
 */
class RotatorView extends View {
    /**
     * @inheritDoc
     */
    constructor(locale) {
        super(locale);
        const t = locale.t;
        const bind = this.bindTemplate;
        /**
         * Defines whether navigation is visible or not.
         *
         * @member {Boolean} #isNavigationVisible
         */
        this.set('isNavigationVisible', true);
        /**
         * Used for checking if a view is focused or not.
         *
         * @type {module:utils/focustracker~FocusTracker}
         */
        this.focusTracker = new FocusTracker();
        /**
         * Navigation button for switching the stack to the previous one.
         *
         * @type {module:ui/button/buttonview~ButtonView}
         */
        this.buttonPrevView = this._createButtonView(t('Previous'), prevIcon);
        /**
         * Navigation button for switching the stack to the next one.
         *
         * @type {module:ui/button/buttonview~ButtonView}
         */
        this.buttonNextView = this._createButtonView(t('Next'), nextIcon);
        /**
         * A collection of the child views that creates the rotator content.
         *
         * @readonly
         * @type {module:ui/viewcollection~ViewCollection}
         */
        this.content = this.createCollection();
        this.setTemplate({
            tag: 'div',
            attributes: {
                class: [
                    'ck',
                    'ck-balloon-rotator'
                ],
                'z-index': '-1'
            },
            children: [
                {
                    tag: 'div',
                    attributes: {
                        class: [
                            'ck-balloon-rotator__navigation',
                            bind.to('isNavigationVisible', value => value ? '' : 'ck-hidden')
                        ]
                    },
                    children: [
                        this.buttonPrevView,
                        {
                            tag: 'span',
                            attributes: {
                                class: [
                                    'ck-balloon-rotator__counter'
                                ]
                            },
                            children: [
                                {
                                    text: bind.to('counter')
                                }
                            ]
                        },
                        this.buttonNextView
                    ]
                },
                {
                    tag: 'div',
                    attributes: {
                        class: 'ck-balloon-rotator__content'
                    },
                    children: this.content
                }
            ]
        });
    }
    /**
     * @inheritDoc
     */
    render() {
        super.render();
        this.focusTracker.add(this.element);
    }
    /**
     * @inheritDoc
     */
    destroy() {
        super.destroy();
        this.focusTracker.destroy();
    }
    /**
     * Shows a given view.
     *
     * @param {module:ui/view~View} view The view to show.
     */
    showView(view) {
        this.hideView();
        this.content.add(view);
    }
    /**
     * Hides the currently displayed view.
     */
    hideView() {
        this.content.clear();
    }
    /**
     * Creates a navigation button view.
     *
     * @private
     * @param {String} label The button label.
     * @param {String} icon The button icon.
     * @returns {module:ui/button/buttonview~ButtonView}
     */
    _createButtonView(label, icon) {
        const view = new ButtonView(this.locale);
        view.set({
            label,
            icon,
            tooltip: true
        });
        return view;
    }
}
// Displays additional layers under the balloon when multiple stacks are added to the balloon.
//
// @private
// @extends module:ui/view~View
class FakePanelsView extends View {
    // @inheritDoc
    constructor(locale, balloonPanelView) {
        super(locale);
        const bind = this.bindTemplate;
        // Fake panels top offset.
        //
        // @observable
        // @member {Number} #top
        this.set('top', 0);
        // Fake panels left offset.
        //
        // @observable
        // @member {Number} #left
        this.set('left', 0);
        // Fake panels height.
        //
        // @observable
        // @member {Number} #height
        this.set('height', 0);
        // Fake panels width.
        //
        // @observable
        // @member {Number} #width
        this.set('width', 0);
        // Number of rendered fake panels.
        //
        // @observable
        // @member {Number} #numberOfPanels
        this.set('numberOfPanels', 0);
        // Collection of the child views which creates fake panel content.
        //
        // @readonly
        // @type {module:ui/viewcollection~ViewCollection}
        this.content = this.createCollection();
        // Context.
        //
        // @private
        // @type {module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
        this._balloonPanelView = balloonPanelView;
        this.setTemplate({
            tag: 'div',
            attributes: {
                class: [
                    'ck-fake-panel',
                    bind.to('numberOfPanels', number => number ? '' : 'ck-hidden')
                ],
                style: {
                    top: bind.to('top', toPx),
                    left: bind.to('left', toPx),
                    width: bind.to('width', toPx),
                    height: bind.to('height', toPx)
                }
            },
            children: this.content
        });
        this.on('change:numberOfPanels', (evt, name, next, prev) => {
            if (next > prev) {
                this._addPanels(next - prev);
            }
            else {
                this._removePanels(prev - next);
            }
            this.updatePosition();
        });
    }
    // @private
    // @param {Number} number
    _addPanels(number) {
        while (number--) {
            const view = new View();
            view.setTemplate({ tag: 'div' });
            this.content.add(view);
            this.registerChild(view);
        }
    }
    // @private
    // @param {Number} number
    _removePanels(number) {
        while (number--) {
            const view = this.content.last;
            this.content.remove(view);
            this.deregisterChild(view);
            view.destroy();
        }
    }
    // Updates coordinates of fake panels.
    updatePosition() {
        if (this.numberOfPanels) {
            const { top, left } = this._balloonPanelView;
            const { width, height } = new Rect(this._balloonPanelView.element);
            Object.assign(this, { top, left, width, height });
        }
    }
}
