import { Button, Spinner } from '@wordpress/components';
import {
useRef,
useCallback,
useEffect,
useLayoutEffect,
useState,
useMemo,
} from '@wordpress/element';
import { sprintf, __ } from '@wordpress/i18n';
import { Icon, close } from '@wordpress/icons';
import { Dialog } from '@headlessui/react';
import classNames from 'classnames';
import { motion, AnimatePresence } from 'framer-motion';
import { useGlobalSyncStore } from '@help-center/state/globals-sync';
import { useTourStore } from '@help-center/state/tours';
import tours from '@help-center/tours/tours';
import availableTours from '@help-center/tours/tours.js';
const getBoundingClientRect = (element) => {
const { top, right, bottom, left, width, height, x, y } =
element.getBoundingClientRect();
return { top, right, bottom, left, width, height, x, y };
};
export const GuidedTour = () => {
const tourBoxRef = useRef();
const {
currentTour,
currentStep,
startTour,
closeCurrentTour,
getStepData,
onTourPage,
} = useTourStore();
const { settings } = currentTour || {};
const { image, title, text, attachTo, events, options } =
getStepData(currentStep);
const { queueTourForRedirect, queuedTour, clearQueuedTour } =
useGlobalSyncStore();
const { element, frame, offset, position, hook, boxPadding } = attachTo || {};
const elementSelector = useMemo(
() => (typeof element === 'function' ? element() : element),
[element],
);
const frameSelector = useMemo(
() => (typeof frame === 'function' ? frame() : frame),
[frame],
);
const offsetNormalized = useMemo(
() => (typeof offset === 'function' ? offset() : offset),
[offset],
);
const hookNormalized = useMemo(
() => (typeof hook === 'function' ? hook() : hook),
[hook],
);
const initialFocus = useRef();
const finishedStepOne = useRef(false);
const [targetedElement, setTargetedElement] = useState(null);
const [redirecting, setRedirecting] = useState(false);
const [visible, setVisible] = useState(false);
const [overlayRect, setOverlayRect] = useState(null);
const [placement, setPlacement] = useState({
x: undefined,
y: undefined,
...offsetNormalized,
});
const setTourBox = useCallback(
(x, y) => {
// x is 20 on mobile, so exclude the offset here
setPlacement(x === 20 ? { x, y } : { x, y, ...offsetNormalized });
},
[offsetNormalized],
);
const getOffset = useCallback(() => {
const hooks = hookNormalized?.split(' ') || [];
return {
x: hooks.includes('right') ? tourBoxRef.current?.offsetWidth : 0,
y: hooks.includes('bottom') ? tourBoxRef.current?.offsetHeight : 0,
};
}, [hookNormalized]);
const startOrRecalc = useCallback(() => {
if (!targetedElement) return;
const frame = frameSelector
? (document.querySelector(frameSelector)?.contentDocument ?? document)
: document;
const rect = getBoundingClientRect(
frame.querySelector(elementSelector) ?? targetedElement,
);
// Adjust the frame position if we're in an iframe
if (frame !== document) {
const frameRect = getBoundingClientRect(frame.defaultView.frameElement);
rect.x += frameRect.x;
rect.left += frameRect.x;
rect.right += frameRect.x;
rect.y += frameRect.y;
rect.top += frameRect.y;
rect.bottom += frameRect.y;
}
if (window.innerWidth <= 960) {
closeCurrentTour('closed-resize');
return;
}
if (position?.x === undefined) {
setTourBox(undefined, undefined);
setOverlayRect(null);
setVisible(false);
return;
}
const x = Math.max(20, rect?.[position.x] - getOffset().x);
const y = Math.max(20, rect?.[position.y] - getOffset().y);
const box = tourBoxRef.current;
// make sure it doesn't go off-screen
setTourBox(
Math.min(x, window.innerWidth - (box?.offsetWidth ?? 0) - 20),
Math.min(y, window.innerHeight - (box?.offsetHeight ?? 0) - 20),
);
setOverlayRect(rect);
}, [
targetedElement,
position,
getOffset,
setTourBox,
frameSelector,
elementSelector,
closeCurrentTour,
]);
// Pre-launch check whether to redirect
useLayoutEffect(() => {
// if the tour has a start from url, redirect there
if (!settings?.startFrom) return;
if (onTourPage()) return;
setRedirecting(true);
queueTourForRedirect(currentTour.id);
closeCurrentTour('redirected');
window.location.assign(settings?.startFrom[0]);
if (
window.location.href.split('#')[0] === settings.startFrom[0].split('#')[0]
) {
// Reload if hash is the only difference
window.location.reload();
}
}, [
settings?.startFrom,
currentTour,
queueTourForRedirect,
closeCurrentTour,
onTourPage,
]);
// Check for the inert attribute and remove it if it exists
useEffect(() => {
if (!currentStep) return;
document
.querySelectorAll('[inert]')
.forEach((el) => el?.removeAttribute('inert'));
}, [currentStep]);
// register a custom event to start the specified tour.
useEffect(() => {
const handle = (event) => {
const { tourSlug } = event.detail;
if (!tours[tourSlug]) return;
requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent('extendify-hc:minimize'));
startTour(tours[tourSlug]);
});
};
window.addEventListener('extendify-assist:start-tour', handle);
return () => {
window.removeEventListener('extendify-assist:start-tour', handle);
};
}, [startTour]);
// Possibly start the tour, or wait for the load event
useLayoutEffect(() => {
if (redirecting) return;
const tour = queuedTour;
let rafId = 0;
if (!tour || !availableTours[tour]) return clearQueuedTour();
const handle = () => {
requestAnimationFrame(() => {
startTour(availableTours[tour]);
});
clearQueuedTour();
};
addEventListener('load', handle);
if (document.readyState === 'complete') {
// Page is already loaded, so we can start the tour immediately
rafId = requestAnimationFrame(handle);
}
return () => {
cancelAnimationFrame(rafId);
removeEventListener('load', handle);
};
}, [startTour, queuedTour, clearQueuedTour, redirecting]);
useEffect(() => {
if (!elementSelector) return;
// Find and set the element we are attaching to
const frame = frameSelector
? (document.querySelector(frameSelector)?.contentDocument ?? document)
: document;
const element =
frame.querySelector(elementSelector) ??
document.querySelector(elementSelector);
if (!element) return;
setTargetedElement(element);
return () => setTargetedElement(null);
}, [frameSelector, elementSelector]);
// Start building the tour step
useLayoutEffect(() => {
if (!targetedElement || redirecting) return;
setVisible(true);
startOrRecalc();
addEventListener('resize', startOrRecalc);
if (!options?.allowPointerEvents) {
targetedElement.style.pointerEvents = 'none';
}
return () => {
removeEventListener('resize', startOrRecalc);
targetedElement.style.pointerEvents = 'auto';
};
}, [redirecting, targetedElement, startOrRecalc, options]);
useEffect(() => {
if (finishedStepOne.current) return;
if (!currentStep) return;
finishedStepOne.current = true;
}, [currentStep]);
// Handle the attach and detach events
useEffect(() => {
if (currentStep === undefined || !targetedElement) return;
events?.onAttach?.(targetedElement);
let inner = 0;
const id = requestAnimationFrame(() => {
targetedElement.scrollIntoView({ block: 'start' });
startOrRecalc();
inner = requestAnimationFrame(startOrRecalc);
});
initialFocus?.current?.focus();
return () => {
events?.onDetach?.(targetedElement);
cancelAnimationFrame(id);
cancelAnimationFrame(inner);
};
}, [currentStep, events, targetedElement, startOrRecalc, initialFocus]);
useLayoutEffect(() => {
if (!settings?.allowOverflow) return;
document.documentElement.classList.add('ext-force-overflow-auto');
return () => {
document.documentElement.classList.remove('ext-force-overflow-auto');
};
}, [settings]);
if (!visible) return null;
const rectWithPadding = addPaddingToRect(overlayRect, boxPadding);
return (
<>