/**
 * @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/tooltipmanager
 */
import View from './view.js';
import BalloonPanelView, { generatePositions } from './panel/balloon/balloonpanelview.js';
import { DomEmitterMixin, ResizeObserver, first, global, isVisible } from '@ckeditor/ckeditor5-utils';
import { isElement, debounce } from 'lodash-es';
import '../theme/components/tooltip/tooltip.css';
const BALLOON_CLASS = 'ck-tooltip';
/**
 * A tooltip manager class for the UI of the editor.
 *
 * **Note**: Most likely you do not have to use the `TooltipManager` API listed below in order to display tooltips. Popular
 * {@glink framework/architecture/ui-library UI components} support tooltips out-of-the-box via observable properties
 * (see {@link module:ui/button/buttonview~ButtonView#tooltip} and {@link module:ui/button/buttonview~ButtonView#tooltipPosition}).
 *
 * # Displaying tooltips
 *
 * To display a tooltip, set `data-cke-tooltip-text` attribute on any DOM element:
 *
 * ```ts
 * domElement.dataset.ckeTooltipText = 'My tooltip';
 * ```
 *
 * The tooltip will show up whenever the user moves the mouse over the element or the element gets focus in DOM.
 *
 * # Positioning tooltips
 *
 * To change the position of the tooltip, use the `data-cke-tooltip-position` attribute (`s`, `se`, `sw`, `n`, `e`, or `w`):
 *
 * ```ts
 * domElement.dataset.ckeTooltipText = 'Tooltip to the north';
 * domElement.dataset.ckeTooltipPosition = 'n';
 * ```
 *
 * # Disabling tooltips
 *
 * In order to disable the tooltip  temporarily, use the `data-cke-tooltip-disabled` attribute:
 *
 * ```ts
 * domElement.dataset.ckeTooltipText = 'Disabled. For now.';
 * domElement.dataset.ckeTooltipDisabled = 'true';
 * ```
 *
 * # Styling tooltips
 *
 * By default, the tooltip has `.ck-tooltip` class and its text inner `.ck-tooltip__text`.
 *
 * If your tooltip requires custom styling, using `data-cke-tooltip-class` attribute will add additional class to the balloon
 * displaying the tooltip:
 *
 * ```ts
 * domElement.dataset.ckeTooltipText = 'Tooltip with a red text';
 * domElement.dataset.ckeTooltipClass = 'my-class';
 * ```
 *
 * ```css
 * .ck.ck-tooltip.my-class { color: red }
 * ```
 *
 * **Note**: This class is a singleton. All editor instances re-use the same instance loaded by
 * {@link module:ui/editorui/editorui~EditorUI} of the first editor.
 */
class TooltipManager extends DomEmitterMixin() {
    /**
     * Creates an instance of the tooltip manager.
     */
    constructor(editor) {
        super();
        /**
         * Stores the reference to the DOM element the tooltip is attached to. `null` when there's no tooltip
         * in the UI.
         */
        this._currentElementWithTooltip = null;
        /**
         * Stores the current tooltip position. `null` when there's no tooltip in the UI.
         */
        this._currentTooltipPosition = null;
        /**
         * An instance of the resize observer that keeps track on target element visibility,
         * when it hides the tooltip should also disappear.
         *
         * {@link module:core/editor/editorconfig~EditorConfig#balloonToolbar configuration}.
         */
        this._resizeObserver = null;
        /**
         * An instance of the mutation observer that keeps track on target element attributes changes.
         */
        this._mutationObserver = null;
        TooltipManager._editors.add(editor);
        // TooltipManager must be a singleton. Multiple instances would mean multiple tooltips attached
        // to the same DOM element with data-cke-tooltip-* attributes.
        if (TooltipManager._instance) {
            return TooltipManager._instance;
        }
        TooltipManager._instance = this;
        this.tooltipTextView = new View(editor.locale);
        this.tooltipTextView.set('text', '');
        this.tooltipTextView.setTemplate({
            tag: 'span',
            attributes: {
                class: [
                    'ck',
                    'ck-tooltip__text'
                ]
            },
            children: [
                {
                    text: this.tooltipTextView.bindTemplate.to('text')
                }
            ]
        });
        this.balloonPanelView = new BalloonPanelView(editor.locale);
        this.balloonPanelView.class = BALLOON_CLASS;
        this.balloonPanelView.content.add(this.tooltipTextView);
        this._mutationObserver = createMutationObserver(() => {
            this._updateTooltipPosition();
        });
        this._pinTooltipDebounced = debounce(this._pinTooltip, 600);
        this._unpinTooltipDebounced = debounce(this._unpinTooltip, 400);
        this.listenTo(global.document, 'keydown', this._onKeyDown.bind(this), { useCapture: true });
        this.listenTo(global.document, 'mouseenter', this._onEnterOrFocus.bind(this), { useCapture: true });
        this.listenTo(global.document, 'mouseleave', this._onLeaveOrBlur.bind(this), { useCapture: true });
        this.listenTo(global.document, 'focus', this._onEnterOrFocus.bind(this), { useCapture: true });
        this.listenTo(global.document, 'blur', this._onLeaveOrBlur.bind(this), { useCapture: true });
        this.listenTo(global.document, 'scroll', this._onScroll.bind(this), { useCapture: true });
        // Because this class is a singleton, its only instance is shared across all editors and connects them through the reference.
        // This causes issues with the ContextWatchdog. When an error is thrown in one editor, the watchdog traverses the references
        // and (because of shared tooltip manager) figures that the error affects all editors and restarts them all.
        // This flag, excludes tooltip manager instance from the traversal and brings ContextWatchdog back to normal.
        // More in https://github.com/ckeditor/ckeditor5/issues/12292.
        this._watchdogExcluded = true;
    }
    /**
     * Destroys the tooltip manager.
     *
     * **Note**: The manager singleton cannot be destroyed until all editors that use it are destroyed.
     *
     * @param editor The editor the manager was created for.
     */
    destroy(editor) {
        const editorBodyViewCollection = editor.ui.view && editor.ui.view.body;
        TooltipManager._editors.delete(editor);
        this.stopListening(editor.ui);
        // Prevent the balloon panel from being destroyed in the EditorUI#destroy() cascade. It should be destroyed along
        // with the last editor only (https://github.com/ckeditor/ckeditor5/issues/12602).
        if (editorBodyViewCollection && editorBodyViewCollection.has(this.balloonPanelView)) {
            editorBodyViewCollection.remove(this.balloonPanelView);
        }
        if (!TooltipManager._editors.size) {
            this._unpinTooltip();
            this.balloonPanelView.destroy();
            this.stopListening();
            TooltipManager._instance = null;
        }
    }
    /**
     * Returns {@link #balloonPanelView} {@link module:utils/dom/position~PositioningFunction positioning functions} for a given position
     * name.
     *
     * @param position Name of the position (`s`, `se`, `sw`, `n`, `e`, or `w`).
     * @returns Positioning functions to be used by the {@link #balloonPanelView}.
     */
    static getPositioningFunctions(position) {
        const defaultPositions = TooltipManager.defaultBalloonPositions;
        return {
            // South is most popular. We can use positioning heuristics to avoid clipping by the viewport with the sane fallback.
            s: [
                defaultPositions.southArrowNorth,
                defaultPositions.southArrowNorthEast,
                defaultPositions.southArrowNorthWest
            ],
            n: [defaultPositions.northArrowSouth],
            e: [defaultPositions.eastArrowWest],
            w: [defaultPositions.westArrowEast],
            sw: [defaultPositions.southArrowNorthEast],
            se: [defaultPositions.southArrowNorthWest]
        }[position];
    }
    /**
     * Handles hiding tooltips on `keydown` in DOM.
     *
     * @param evt An object containing information about the fired event.
     * @param domEvent The DOM event.
     */
    _onKeyDown(evt, domEvent) {
        if (domEvent.key === 'Escape' && this._currentElementWithTooltip) {
            this._unpinTooltip();
            domEvent.stopPropagation();
        }
    }
    /**
     * Handles displaying tooltips on `mouseenter` and `focus` in DOM.
     *
     * @param evt An object containing information about the fired event.
     * @param domEvent The DOM event.
     */
    _onEnterOrFocus(evt, { target }) {
        const elementWithTooltipAttribute = getDescendantWithTooltip(target);
        // Abort when there's no descendant needing tooltip.
        if (!elementWithTooltipAttribute) {
            // Unpin if element is focused, regardless of whether it contains a label or not.
            // It also prevents tooltips from overlapping the menu bar
            if (evt.name === 'focus') {
                this._unpinTooltip();
            }
            return;
        }
        // Abort to avoid flashing when, for instance:
        // * a tooltip is displayed for a focused element, then the same element gets mouseentered,
        // * a tooltip is displayed for an element via mouseenter, then the focus moves to the same element.
        if (elementWithTooltipAttribute === this._currentElementWithTooltip) {
            return;
        }
        this._unpinTooltip();
        this._pinTooltipDebounced(elementWithTooltipAttribute, getTooltipData(elementWithTooltipAttribute));
    }
    /**
     * Handles hiding tooltips on `mouseleave` and `blur` in DOM.
     *
     * @param evt An object containing information about the fired event.
     * @param domEvent The DOM event.
     */
    _onLeaveOrBlur(evt, { target, relatedTarget }) {
        if (evt.name === 'mouseleave') {
            // Don't act when the event does not concern a DOM element (e.g. a mouseleave out of an entire document),
            if (!isElement(target)) {
                return;
            }
            const balloonElement = this.balloonPanelView.element;
            const isEnteringBalloon = balloonElement && (balloonElement === relatedTarget || balloonElement.contains(relatedTarget));
            const isLeavingBalloon = !isEnteringBalloon && target === balloonElement;
            // Do not hide the tooltip when the user moves the cursor over it.
            if (isEnteringBalloon) {
                this._unpinTooltipDebounced.cancel();
                return;
            }
            // If a tooltip is currently visible, don't act for a targets other than the one it is attached to.
            // The only exception is leaving balloon, in this scenario tooltip should be closed.
            // For instance, a random mouseleave far away in the page should not unpin the tooltip that was pinned because
            // of a previous focus. Only leaving the same element should hide the tooltip.
            if (!isLeavingBalloon && this._currentElementWithTooltip && target !== this._currentElementWithTooltip) {
                return;
            }
            const descendantWithTooltip = getDescendantWithTooltip(target);
            const relatedDescendantWithTooltip = getDescendantWithTooltip(relatedTarget);
            // Unpin when the mouse was leaving element with a tooltip to a place which does not have or has a different tooltip.
            // Note that this should happen whether the tooltip is already visible or not, for instance,
            // it could be invisible but queued (debounced): it should get canceled.
            if (isLeavingBalloon || (descendantWithTooltip && descendantWithTooltip !== relatedDescendantWithTooltip)) {
                this._unpinTooltipDebounced();
            }
        }
        else {
            // If a tooltip is currently visible, don't act for a targets other than the one it is attached to.
            // For instance, a random blur in the web page should not unpin the tooltip that was pinned because of a previous mouseenter.
            if (this._currentElementWithTooltip && target !== this._currentElementWithTooltip) {
                return;
            }
            // Note that unpinning should happen whether the tooltip is already visible or not, for instance, it could be invisible but
            // queued (debounced): it should get canceled (e.g. quick focus then quick blur using the keyboard).
            this._unpinTooltipDebounced();
        }
    }
    /**
     * Handles hiding tooltips on `scroll` in DOM.
     *
     * @param evt An object containing information about the fired event.
     * @param domEvent The DOM event.
     */
    _onScroll(evt, { target }) {
        // No tooltip, no reason to react on scroll.
        if (!this._currentElementWithTooltip) {
            return;
        }
        // When scrolling a container that has both the balloon and the current element (common ancestor), the balloon can remain
        // visible (e.g. scrolling ≤body>). Otherwise, to avoid glitches (clipping, lagging) better just hide the tooltip.
        // Also, don't do anything when scrolling an unrelated DOM element that has nothing to do with the current element and the balloon.
        if (target.contains(this.balloonPanelView.element) && target.contains(this._currentElementWithTooltip)) {
            return;
        }
        this._unpinTooltip();
    }
    /**
     * Pins the tooltip to a specific DOM element.
     *
     * @param options.text Text of the tooltip to display.
     * @param options.position The position of the tooltip.
     * @param options.cssClass Additional CSS class of the balloon with the tooltip.
     */
    _pinTooltip(targetDomElement, { text, position, cssClass }) {
        this._unpinTooltip();
        // Use the body collection of the first editor.
        const bodyViewCollection = first(TooltipManager._editors.values()).ui.view.body;
        if (!bodyViewCollection.has(this.balloonPanelView)) {
            bodyViewCollection.add(this.balloonPanelView);
        }
        this.tooltipTextView.text = text;
        this.balloonPanelView.pin({
            target: targetDomElement,
            positions: TooltipManager.getPositioningFunctions(position)
        });
        this._resizeObserver = new ResizeObserver(targetDomElement, () => {
            // The ResizeObserver will call its callback when the target element hides and the tooltip
            // should also disappear (https://github.com/ckeditor/ckeditor5/issues/12492).
            if (!isVisible(targetDomElement)) {
                this._unpinTooltip();
            }
        });
        this._mutationObserver.attach(targetDomElement);
        this.balloonPanelView.class = [BALLOON_CLASS, cssClass]
            .filter(className => className)
            .join(' ');
        // Start responding to changes in editor UI or content layout. For instance, when collaborators change content
        // and a contextual toolbar attached to a content starts to move (and so should move the tooltip).
        // Note: Using low priority to let other listeners that position contextual toolbars etc. to react first.
        for (const editor of TooltipManager._editors) {
            this.listenTo(editor.ui, 'update', this._updateTooltipPosition.bind(this), { priority: 'low' });
        }
        this._currentElementWithTooltip = targetDomElement;
        this._currentTooltipPosition = position;
    }
    /**
     * Unpins the tooltip and cancels all queued pinning.
     */
    _unpinTooltip() {
        this._unpinTooltipDebounced.cancel();
        this._pinTooltipDebounced.cancel();
        this.balloonPanelView.unpin();
        for (const editor of TooltipManager._editors) {
            this.stopListening(editor.ui, 'update');
        }
        this._currentElementWithTooltip = null;
        this._currentTooltipPosition = null;
        this.tooltipTextView.text = '';
        if (this._resizeObserver) {
            this._resizeObserver.destroy();
        }
        this._mutationObserver.detach();
    }
    /**
     * Updates the position of the tooltip so it stays in sync with the element it is pinned to.
     *
     * Hides the tooltip when the element is no longer visible in DOM or the tooltip text was removed.
     */
    _updateTooltipPosition() {
        const tooltipData = getTooltipData(this._currentElementWithTooltip);
        // This could happen if the tooltip was attached somewhere in a contextual content toolbar and the toolbar
        // disappeared (e.g. removed an image), or the tooltip text was removed.
        if (!isVisible(this._currentElementWithTooltip) || !tooltipData.text) {
            this._unpinTooltip();
            return;
        }
        this.balloonPanelView.pin({
            target: this._currentElementWithTooltip,
            positions: TooltipManager.getPositioningFunctions(tooltipData.position)
        });
    }
}
/**
 * A set of default {@link module:utils/dom/position~PositioningFunction positioning functions} used by the `TooltipManager`
 * to pin tooltips in different positions.
 */
TooltipManager.defaultBalloonPositions = generatePositions({
    heightOffset: 5,
    sideOffset: 13
});
/**
 * A set of editors the single tooltip manager instance must listen to.
 * This is mostly to handle `EditorUI#update` listeners from individual editors.
 */
TooltipManager._editors = new Set();
/**
 * A reference to the `TooltipManager` instance. The class is a singleton and as such,
 * successive attempts at creating instances should return this instance.
 */
TooltipManager._instance = null;
export default TooltipManager;
function getDescendantWithTooltip(element) {
    if (!isElement(element)) {
        return null;
    }
    return element.closest('[data-cke-tooltip-text]:not([data-cke-tooltip-disabled])');
}
function getTooltipData(element) {
    return {
        text: element.dataset.ckeTooltipText,
        position: (element.dataset.ckeTooltipPosition || 's'),
        cssClass: element.dataset.ckeTooltipClass || ''
    };
}
// Creates a simple `MutationObserver` instance wrapper that observes changes in the tooltip-related attributes of the given element.
// Used instead of the `MutationObserver` from the engine for simplicity.
function createMutationObserver(callback) {
    const mutationObserver = new MutationObserver(() => {
        callback();
    });
    return {
        attach(element) {
            mutationObserver.observe(element, {
                attributes: true,
                attributeFilter: ['data-cke-tooltip-text', 'data-cke-tooltip-position']
            });
        },
        detach() {
            mutationObserver.disconnect();
        }
    };
}
