Source: segments-experiment-web/src/main/resources/META-INF/resources/js/components/ClickGoalPicker/ClickGoalPicker.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 ClayButton from '@clayui/button';
import ClayIcon from '@clayui/icon';
import ClayLink from '@clayui/link';
import classNames from 'classnames';
import {useEventListener} from 'frontend-js-react-web';
import {throttle} from 'frontend-js-web';
import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
import {getInitialState, reducer, StateContext} from './reducer.es';
import {
	GeometryType,
	getTargetableElements,
	stopImmediatePropagation,
	getRootElementGeometry,
	getElementGeometry
} from './utils.es';

const {
	useCallback,
	useContext,
	useEffect,
	useLayoutEffect,
	useRef,
	useReducer,
	useState
} = React;

const ESCAPE_KEYS = [
	'Escape', // Most browsers.
	'Esc' // IE and Edge.
];

const POPOVER_PADDING = 16;

const THROTTLE_INTERVAL_MS = 100;

const DispatchContext = React.createContext();

/**
 * Top-level entry point for displaying, selecting, editing and removing click
 * goal targets.
 */
function ClickGoalPicker({allowEdit = true, onSelectClickGoalTarget, target}) {
	const [state, dispatch] = useReducer(reducer, target, getInitialState);

	const {selectedTarget} = state;

	const ref = useRef(state.selectedTarget);

	useEffect(() => {
		ref.current = selectedTarget;
	}, [selectedTarget]);

	const previousTarget = ref.current;

	if (selectedTarget != previousTarget) {
		if (onSelectClickGoalTarget) {
			onSelectClickGoalTarget(selectedTarget);
		}
	}

	if (process.env.NODE_ENV === 'test') {
		if (!document.getElementById('content')) {
			const content = document.createElement('div');

			content.id = 'content';

			document.body.appendChild(content);
		}
	}

	const root = document.getElementById('content');

	if (!root) {
		console.error(
			'Cannot render <SegmentsExperimentsClickGoal /> without #content element',
			document.querySelectorAll('section').length
		);
		return null;
	}

	if (process.env.NODE_ENV === 'development') {
		const style = getComputedStyle(root);

		if (style.position === 'static') {
			console.warn(
				'<SegmentsExperimentsClickGoal /> requires that #content be a positioned element (ie. not position: "static")'
			);
		}
	}

	const scrollIntoView = event => {
		const target = document.querySelector(state.selectedTarget);

		if (target) {
			target.scrollIntoView();

			// Make sure nothing slides under the top nav.
			window.scrollBy(0, -100);
		}

		event.preventDefault();

		dispatch({type: 'activate'});
	};

	return (
		<DispatchContext.Provider value={dispatch}>
			<StateContext.Provider value={state}>
				<h4 className="mb-3 mt-4 sheet-subtitle">
					{Liferay.Language.get('click-goal')}
				</h4>

				<dl className="mb-0">
					<div className="d-flex">
						<dt>{Liferay.Language.get('element')}:</dt>
						{state.selectedTarget ? (
							<dd className="ml-2 text-truncate">
								<ClayLink
									href={state.selectedTarget}
									onClick={scrollIntoView}
									title={state.selectedTarget}
								>
									{state.selectedTarget}
								</ClayLink>
							</dd>
						) : (
							<dd className="ml-2 text-secondary">
								{Liferay.Language.get(
									'select-element-to-be-measured'
								)}
							</dd>
						)}
					</div>
				</dl>

				{allowEdit && (
					<ClayButton
						disabled={state.mode === 'active'}
						displayType="secondary"
						onClick={() => dispatch({type: 'activate'})}
						small
					>
						{state.selectedTarget
							? Liferay.Language.get('edit-element')
							: Liferay.Language.get('set-element')}
					</ClayButton>
				)}

				{state.mode === 'active' ? (
					<ClickGoalPicker.OverlayContainer
						allowEdit={allowEdit}
						root={root}
					/>
				) : null}
			</StateContext.Provider>
		</DispatchContext.Provider>
	);
}

ClickGoalPicker.propTypes = {
	allowEdit: PropTypes.bool,
	onSelectClickGoalTarget: PropTypes.func,
	target: PropTypes.string
};

/**
 * Responsible for performing the "full-screen takeover" and mounting the
 * <Overlay /> component when active.
 */
function OverlayContainer({root, allowEdit}) {
	const cssId = 'segments-experiments-click-goal-css-overrides';

	const dispatch = useContext(DispatchContext);

	const targetableElements = useRef();

	// Before mount.
	if (!targetableElements.current) {
		// Apply CSS overrides.
		const css = `
			#banner {
				cursor: not-allowed;
			}
			#banner a, #banner button {
				cursor: not-allowed;
				pointer-events: none;
			}
			#content {
				position: relative;
				cursor: not-allowed;
			}

			.portlet-content-editable {
				border-color: transparent;
			}

			.portlet-topper {
				visibility: hidden;
			}
		`;
		const head = document.head;
		const style = document.createElement('style');

		style.id = cssId;
		style.type = 'text/css';
		style.appendChild(document.createTextNode(css));

		head.appendChild(style);

		// This must happen after hiding the toppers.
		targetableElements.current = getTargetableElements(root);
	}

	// On unmount.
	useEffect(() => {
		return () => {
			// Remove CSS overrides.
			const style = document.getElementById(cssId);

			if (style) {
				style.parentNode.removeChild(style);
			}
		};
	}, []);

	const handleKeydown = useCallback(
		event => {
			if (ESCAPE_KEYS.includes(event.key)) {
				dispatch({type: 'deactivate'});
				event.preventDefault();
				stopImmediatePropagation(event);
			}
		},
		[dispatch]
	);

	const handleClick = useCallback(
		event => {
			// Clicking anywhere other than a target aborts target selection.
			event.preventDefault();
			stopImmediatePropagation(event);
			dispatch({type: 'deactivate'});
		},
		[dispatch]
	);

	useEventListener('keydown', handleKeydown, true, document);

	useEventListener('click', handleClick, false, document);

	return ReactDOM.createPortal(
		<ClickGoalPicker.Overlay
			allowEdit={allowEdit}
			root={root}
			targetableElements={targetableElements.current}
		/>,
		root
	);
}

OverlayContainer.propTypes = {
	allowEdit: PropTypes.bool,
	root: PropTypes.instanceOf(Element).isRequired
};

/**
 * Covers the main content surface of the page (the "#content" element)
 * and draws UI over each targetable element.
 */
function Overlay({allowEdit, root, targetableElements}) {
	const {editingTarget, selectedTarget} = useContext(StateContext);

	const [geometry, setGeometry] = useState(getRootElementGeometry(root));

	const handleResize = useCallback(
		throttle(() => {
			setGeometry(getRootElementGeometry(root));
		}, THROTTLE_INTERVAL_MS),
		[root]
	);

	// For now, treat scrolling just like resizing.
	const handleScroll = handleResize;

	useEventListener('resize', handleResize, false, window);

	// TODO: also consider scrolling of elements with "overflow: auto/scroll";
	useEventListener('scroll', handleScroll, false, window);

	return (
		<div className="lfr-segments-experiment-click-goal-root">
			{targetableElements
				.filter(element => {
					if (allowEdit === true) return true;
					if ('#' + element.id === selectedTarget) return true;
					return false;
				})
				.map(element => {
					const selector = `#${element.id}`;

					const mode =
						editingTarget === selector && allowEdit
							? 'editing'
							: selectedTarget === selector
							? 'selected'
							: 'inactive';

					return (
						<ClickGoalPicker.Target
							allowEdit={allowEdit}
							element={element}
							geometry={geometry}
							key={selector}
							mode={mode}
							selector={selector}
						/>
					);
				})}
		</div>
	);
}

Overlay.propTypes = {
	allowEdit: PropTypes.bool,
	root: PropTypes.instanceOf(Element).isRequired,
	targetableElements: PropTypes.arrayOf(PropTypes.instanceOf(Element))
		.isRequired
};

/**
 * Draws three controls above/on-top-of/below a potential click goal target:
 *
 * - A <TargetTopper />, above the target, which labels the target and can be
 *   used to undo the selection of that target.
 * - An rectangle on-top-of the target which can be clicked on to select it.
 * - A <TargetPopover />, below the target, that provides contextual information
 *   and a button to confirm the selection of that target.
 */
function Target({allowEdit, element, geometry, mode, selector}) {
	const dispatch = useContext(DispatchContext);

	const {bottom, height, left, right, width, top} = getElementGeometry(
		element
	);

	if (!bottom && !top && !right && !left) {
		return null;
	}

	const handleClick = event => {
		dispatch({
			selector,
			type: 'editTarget'
		});

		stopImmediatePropagation(event);

		// TODO: replace this hack; needed because we have to stop propagation
		// and that means the tooltip stays on screen.
		Liferay.Data.LFR_PORTAL_TOOLTIP.getTooltip().hide();
	};

	// At this point we don't know the dimensions of our children, but we do
	// know whether we have more space on the left or right of our target, so we
	// flip based on that.
	const spaceOnLeft = left - geometry.left;
	const spaceOnRight = geometry.right - right;
	const spaceOnTop = top - geometry.top;
	const align = spaceOnRight > spaceOnLeft ? 'left' : 'right';

	return (
		// TODO: make tooltip match mock and switch to Clay v3 tooltips directly
		// instead of using lfr-portal-tooltip.
		<div
			className="lfr-segments-experiment-click-goal-target"
			style={{
				alignItems: align === 'left' ? 'flex-start' : 'flex-end',
				left: align === 'left' ? spaceOnLeft : null,
				right: align === 'right' ? spaceOnRight : null,
				top: spaceOnTop
			}}
		>
			<div
				className={classNames({
					'lfr-portal-tooltip': mode === 'inactive',
					'lfr-segments-experiment-click-goal-target-overlay': true,
					'lfr-segments-experiment-click-goal-target-overlay-editing':
						mode === 'editing',
					'lfr-segments-experiment-click-goal-target-overlay-selected':
						mode === 'selected'
				})}
				data-target-selector={selector}
				data-title={
					mode === 'inactive'
						? Liferay.Language.get(
								'click-element-to-set-as-click-target-for-your-goal'
						  )
						: ''
				}
				onClick={handleClick}
				style={{height, width}}
			></div>
			{mode !== 'inactive' && (
				<ClickGoalPicker.TargetTopper
					allowEdit={allowEdit}
					element={element}
					geometry={geometry}
					isEditing={mode === 'editing'}
					selector={selector}
				/>
			)}
			{mode === 'editing' && (
				<ClickGoalPicker.TargetPopover selector={selector} />
			)}
		</div>
	);
}

Target.propTypes = {
	allowEdit: PropTypes.bool,
	element: PropTypes.instanceOf(Element).isRequired,
	geometry: GeometryType.isRequired,
	mode: PropTypes.oneOf(['inactive', 'selected', 'editing']).isRequired,
	selector: PropTypes.string.isRequired
};

/**
 * Drawn above a click target, and includes a button to undo the selection of
 * that target.
 */
function TargetTopper({allowEdit, geometry, isEditing, selector}) {
	const dispatch = useContext(DispatchContext);

	const topperRef = useRef();

	const [top, setTop] = useState(0);

	const [width, setWidth] = useState(null);

	useLayoutEffect(() => {
		if (topperRef.current) {
			const {
				height,
				left,
				width
			} = topperRef.current.getBoundingClientRect();

			setTop(-height);

			setWidth(Math.min(geometry.width - (left - geometry.left), width));
		}
	}, [geometry.left, geometry.width]);

	const handleClick = event => {
		stopImmediatePropagation(event);

		dispatch({
			selector: '',
			type: 'selectTarget'
		});
	};

	return (
		<div
			className={classNames({
				'd-flex': true,
				'lfr-segments-experiment-click-goal-target-topper': true,
				'lfr-segments-experiment-click-goal-target-topper-editing': isEditing,
				'px-2': true,
				small: true,
				'text-white': true
			})}
			onClick={stopImmediatePropagation}
			ref={topperRef}
			style={{
				maxWidth: width !== null ? `${width}px` : null,
				top: `${top}px`
			}}
		>
			<span className="mr-2 text-truncate">
				{isEditing ? selector : Liferay.Language.get('target')}
			</span>
			{allowEdit && (
				<ClayButton
					className="lfr-segments-experiment-click-goal-target-delete small text-white"
					displayType="unstyled"
					onClick={handleClick}
				>
					<ClayIcon symbol="times-circle" />
				</ClayButton>
			)}
		</div>
	);
}

TargetTopper.propTypes = {
	allowEdit: PropTypes.bool,
	geometry: GeometryType.isRequired,
	isEditing: PropTypes.bool.isRequired,
	selector: PropTypes.string.isRequired
};

/**
 * Drawn below a click target, and provides contextual information and a button
 * to confirm the selection of that target.
 */
function TargetPopover({selector}) {
	const dispatch = useContext(DispatchContext);

	const buttonRef = useRef();

	const [buttonWidth, setButtonWidth] = useState(null);

	useLayoutEffect(() => {
		if (buttonRef.current) {
			setButtonWidth(buttonRef.current.offsetWidth);
		}
	}, []);

	// The +1 here is to avoid unwanted wrapping of the button.
	const maxWidth = buttonWidth
		? `${buttonWidth + POPOVER_PADDING * 2 + 1}px`
		: 'none';

	const handleClick = () => {
		dispatch({
			selector,
			type: 'selectTarget'
		});
	};

	return (
		<div
			className="lfr-segments-experiment-click-goal-target-popover p-3"
			onClick={stopImmediatePropagation}
			style={{maxWidth}}
		>
			<div className="mb-2 text-secondary text-truncate" title={selector}>
				{selector}
			</div>
			<ClayButton onClick={handleClick} ref={buttonRef}>
				{Liferay.Language.get('set-element-as-click-target')}
			</ClayButton>
		</div>
	);
}

TargetPopover.propTypes = {
	selector: PropTypes.string.isRequired
};

ClickGoalPicker.Overlay = Overlay;
ClickGoalPicker.OverlayContainer = OverlayContainer;
ClickGoalPicker.Target = Target;
ClickGoalPicker.TargetPopover = TargetPopover;
ClickGoalPicker.TargetTopper = TargetTopper;

export default ClickGoalPicker;