Source: layout-content-page-editor-web/src/main/resources/META-INF/resources/js/components/floating_toolbar/FloatingToolbar.es.js

/**
 * Copyright (c) 2000-present Liferay, Inc. All rights reserved.
 *
 * This library is free software; you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation; either version 2.1 of the License, or (at your option)
 * any later version.
 *
 * This library is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 */

import Component from 'metal-component';
import {Align} from 'metal-position';
import Soy from 'metal-soy';
import {Config} from 'metal-state';

import getConnectedComponent from '../../store/ConnectedComponent.es';
import {setIn} from '../../utils/FragmentsEditorUpdateUtils.es';
import templates from './FloatingToolbar.soy';

/**
 * @type {object}
 */
const FIXED_PANEL_CLASS = 'fragments-editor__floating-toolbar-panel--fixed';

/**
 * @type {object}
 */
const ELEMENT_AVAILABLE_POSITIONS = {
	bottom: [
		Align.Bottom,
		Align.BottomCenter,
		Align.BottomLeft,
		Align.BottomRight
	],

	left: [Align.BottomLeft, Align.Left, Align.LeftCenter, Align.TopRight],
	right: [Align.BottomRight, Align.Right, Align.RightCenter, Align.TopRight],
	top: [Align.Top, Align.TopCenter, Align.TopLeft, Align.TopRight]
};

/**
 * @type {object}
 */
const ELEMENT_POSITION = {
	bottom: {
		left: Align.BottomLeft,
		right: Align.BottomRight
	},

	top: {
		left: Align.TopLeft,
		right: Align.TopRight
	}
};

/**
 * FloatingToolbar
 */
class FloatingToolbar extends Component {
	/**
	 * Gets a suggested align of an element to an anchor, following this logic:
	 * - Vertically, if the element fits at bottom, it's placed there, otherwise
	 *   it is placed at top.
	 * - Horizontally, the element is placed depending on the anchor position
	 *   relative to the wrapper.
	 * @param {HTMLElement} wrapper
	 * @param {HTMLElement|null} element
	 * @param {HTMLElement|null} anchor
	 * @private
	 * @return {number} Selected align
	 * @review
	 */
	static _getElementAlign(wrapper, element, anchor) {
		const alignFits = (align, availableAlign) =>
			availableAlign.includes(
				Align.suggestAlignBestRegion(element, anchor, align).position
			);

		const anchorRect = anchor.getBoundingClientRect();
		const fragmentEntryLinkListWidth = wrapper.offsetWidth;

		const horizontal =
			anchorRect.right > fragmentEntryLinkListWidth / 2
				? 'right'
				: 'left';

		const fallbackVertical = 'top';
		let vertical = 'bottom';

		if (
			!alignFits(
				ELEMENT_POSITION[vertical][horizontal],
				ELEMENT_AVAILABLE_POSITIONS[vertical]
			) &&
			alignFits(
				ELEMENT_POSITION[fallbackVertical][horizontal],
				ELEMENT_AVAILABLE_POSITIONS[fallbackVertical]
			)
		) {
			vertical = fallbackVertical;
		}

		return ELEMENT_POSITION[vertical][horizontal];
	}

	/**
	 * Gets the height of the element matching the selector
	 * Defaults to 0
	 * @param {string} selector
	 */
	static _getElementHeight(selector) {
		const element = document.querySelector(selector);

		if (element) {
			return element.offsetHeight;
		}

		return 0;
	}

	/**
	 * @inheritdoc
	 * @review
	 */
	created() {
		this._defaultButtonClicked = this._defaultButtonClicked.bind(this);
		this._handleWindowResize = this._handleWindowResize.bind(this);
		this._handleWrapperScroll = this._handleWrapperScroll.bind(this);

		this._lastSelectedPanelId = this.selectedPanelId;

		this._managementBarHeight = FloatingToolbar._getElementHeight(
			'.management-bar'
		);
		this._productMenuHeight = FloatingToolbar._getElementHeight(
			'.control-menu'
		);

		window.addEventListener('resize', this._handleWindowResize);

		this._wrapper = document.querySelector(
			'.fragment-entry-link-list-wrapper'
		);

		this._wrapper.addEventListener('scroll', this._handleWrapperScroll);
	}

	/**
	 * @inheritDoc
	 */
	attached() {
		this.addListener('buttonClicked', this._defaultButtonClicked, true);
	}

	/**
	 * @inheritdoc
	 * @review
	 */
	disposed() {
		window.removeEventListener('resize', this._handleWindowResize);

		this._wrapper.removeEventListener('scroll', this._handleWrapperScroll);
	}

	/**
	 * @inheritdoc
	 * @review
	 */
	prepareStateForRender(state) {
		let nextState = state;

		nextState = setIn(
			nextState,
			['selectedPanelId'],
			this._isAnchorElementVisible() ? state.selectedPanelId : null
		);

		return nextState;
	}

	/**
	 * @inheritdoc
	 * @review
	 */
	rendered() {
		this._setFixedPanelClass();

		requestAnimationFrame(() => {
			this._align();
		});
	}

	/**
	 * @inheritdoc
	 * @review
	 */
	syncSelectedPanelId() {
		requestAnimationFrame(() => {
			if (this.refs.selectedPanel) {
				this.refs.selectedPanel.on('clearEditor', () =>
					this.emit('clearEditor')
				);

				this.refs.selectedPanel.on('createProcessor', () =>
					this.emit('createProcessor')
				);
			}
		});
	}

	/**
	 * Select or deselect panel. Default handler for button clicked event.
	 * @param {Event} event
	 * @param {Object} data
	 * @private
	 */
	_defaultButtonClicked(event, data) {
		const {panelId} = data;

		if (!event.defaultPrevented) {
			if (this.selectedPanelId === panelId) {
				this.selectedPanelId = null;
			} else {
				this.selectedPanelId = panelId;
				this._lastSelectedPanelId = panelId;
			}
		}
	}
	/**
	 * Controls the visibility of the panel.
	 * The panel will be shown only when the anchor element is visible
	 * @private
	 * @review
	 */
	_handlePanelVisibilityOnScroll() {
		if (!this._isAnchorElementVisible()) {
			this.selectedPanelId = null;
		} else {
			if (this._lastSelectedPanelId && !this.selectedPanelId) {
				this.selectedPanelId = this._lastSelectedPanelId;
			}
		}
	}

	/**
	 * Handle panel button click
	 * @param {MouseEvent} event Click event
	 */
	_handlePanelButtonClick(event) {
		const {panelId = null, type} = event.delegateTarget.dataset;

		this.emit('buttonClicked', event, {
			panelId,
			type
		});
	}

	/**
	 * @private
	 * @review
	 */
	_handleWindowResize() {
		this._align();
	}

	/**
	 * @private
	 * @review
	 */
	_handleWrapperScroll() {
		this._align();
		this._handlePanelVisibilityOnScroll();
	}

	/**
	 * Check whether the anchor element is visible or not
	 * @private
	 * @review
	 */
	_isAnchorElementVisible() {
		if (this.anchorElement) {
			const anchorElementRect = this.anchorElement.getBoundingClientRect();
			const anchorElementBottom =
				anchorElementRect.y + anchorElementRect.height;

			return (
				anchorElementBottom >
				this._productMenuHeight + this._managementBarHeight
			);
		} else {
			return false;
		}
	}

	/**
	 * Aligns the FloatingToolbar to the anchorElement
	 * @private
	 * @review
	 */
	_align() {
		AUI().use('portal-available-languages', () => {
			if (this.refs.buttons && this.anchorElement) {
				const buttonsAlign = FloatingToolbar._getElementAlign(
					this._wrapper,
					this.refs.panel || this.refs.buttons,
					this.anchorElement
				);

				Align.align(
					this.refs.buttons,
					this.anchorElement,
					buttonsAlign,
					false
				);

				requestAnimationFrame(() => {
					this._alignPanel();
				});
			} else if (this.anchorElement) {
				this._alignPanel();
			}
		});
	}

	/**
	 * Align FloatingToolbar panel to it's buttons or anchorElement
	 * @private
	 * @review
	 */
	_alignPanel() {
		if (this.refs.panel && this.anchorElement) {
			const panelAlign = FloatingToolbar._getElementAlign(
				this._wrapper,
				this.refs.panel,
				this.refs.buttons || this.anchorElement
			);

			Align.align(
				this.refs.panel,
				this.refs.buttons || this.anchorElement,
				panelAlign,
				false
			);
		}
	}

	/**
	 * Add fixed CSS class to panel if buttons are not shown
	 * @private
	 * @review
	 */
	_setFixedPanelClass() {
		if (this.refs.panel && !this.refs.buttons) {
			this.refs.panel.classList.add(FIXED_PANEL_CLASS);
		}
	}
}

/**
 * State definition.
 * @review
 * @static
 * @type {!Object}
 */
FloatingToolbar.STATE = {
	/**
	 * Used for restoring the panel after hiding it
	 * @default null
	 * @instance
	 * @memberOf FloatingToolbar
	 * @private
	 * @review
	 * @type {string|null}
	 */
	_lastSelectedPanelId: Config.string()
		.internal()
		.value(null),

	/**
	 * @default 0
	 * @instance
	 * @memberOf FloatingToolbar
	 * @private
	 * @review
	 * @type {number}
	 */
	_managementBarHeight: Config.number()
		.internal()
		.value(0),

	/**
	 * @default 0
	 * @instance
	 * @memberOf FloatingToolbar
	 * @private
	 * @review
	 * @type {number}
	 */
	_productMenuHeight: Config.number()
		.internal()
		.value(0),

	/**
	 * @default null
	 * @instance
	 * @memberof FloatingToolbar
	 * @private
	 * @review
	 * @type {object}
	 */
	_wrapper: Config.object()
		.internal()
		.value(null),

	/**
	 * Element where the floating toolbar is positioned with
	 * @default undefined
	 * @instance
	 * @memberof FloatingToolbar
	 * @review
	 * @type {HTMLElement}
	 */
	anchorElement: Config.instanceOf(HTMLElement).required(),

	/**
	 * List of available buttons.
	 * @default undefined
	 * @instance
	 * @memberOf FloatingToolbar
	 * @review
	 * @type {object[]}
	 */
	buttons: Config.arrayOf(
		Config.shapeOf({
			cssClass: Config.string(),
			icon: Config.string(),
			id: Config.string(),
			panelId: Config.string(),
			title: Config.string(),
			type: Config.string()
		})
	).required(),

	/**
	 * If true, once a panel has been selected it cannot be changed
	 * until selectedPanelId is set manually to null.
	 * @default false
	 * @instance
	 * @memberof FloatingToolbar
	 * @review
	 * @type {boolean}
	 */
	fixSelectedPanel: Config.bool().value(false),

	/**
	 * Selected panel ID.
	 * @default null
	 * @instance
	 * @memberOf FloatingToolbar
	 * @private
	 * @review
	 * @type {string|null}
	 */
	selectedPanelId: Config.string()
		.internal()
		.value(null)
};

const ConnectedFloatingToolbar = getConnectedComponent(FloatingToolbar, [
	'spritemap'
]);

Soy.register(ConnectedFloatingToolbar, templates);

export {ConnectedFloatingToolbar, FloatingToolbar};
export default ConnectedFloatingToolbar;