UNPKG

terriajs

Version:

Geospatial data visualization platform.

390 lines (375 loc) 11.5 kB
/** * TourPortal.jsx * Framework for tour * * Not a real "portal" in the sense of a react portal, even though it * started out as wanting to be that. Our not-yet-invented "new modal system" * will probably utilise a react portal, though. * * TODO: loop through configparameters for ability to customise at runtime * , then add docs for customisation */ import { autorun } from "mobx"; import { observer } from "mobx-react"; import PropTypes from "prop-types"; import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useTheme, withTheme } from "styled-components"; import Box from "../../Styled/Box"; import Button from "../../Styled/Button"; import Spacing from "../../Styled/Spacing"; import Text from "../../Styled/Text"; import { parseCustomMarkdownToReactWithOptions } from "../Custom/parseCustomMarkdownToReact"; import Caret from "../Generic/Caret"; import CloseButton from "../Generic/CloseButton"; import { useWindowSize } from "../Hooks/useWindowSize"; import { useViewState } from "../StandardUserInterface/ViewStateContext"; import { applyTranslationIfExists } from "./../../Language/languageHelpers"; import { calculateLeftPosition, calculateTopPosition, getOffsetsFromTourPoint } from "./tour-helpers.ts"; import TourExplanationBox, { TourExplanationBoxZIndex } from "./TourExplanationBox.jsx"; import TourIndicator from "./TourIndicator.jsx"; import TourOverlay from "./TourOverlay.jsx"; import TourPrefaceBox from "./TourPrefaceBox.jsx"; import TourProgressDot from "./TourProgressDot.jsx"; /** * Indicator bar/"dots" on progress of tour. * Fill in indicator dot depending on progress determined from count & max count */ const TourProgress = ({ max, step, setTourIndex }) => { const countArray = Array.from(Array(max).keys()).map((e) => e++); const countStep = step; return ( <Box centered> {countArray.map((count) => { return ( <TourProgressDot onClick={() => setTourIndex(count)} key={count} active={count < countStep} /> ); })} </Box> ); }; TourProgress.propTypes = { setTourIndex: PropTypes.func.isRequired, max: PropTypes.number.isRequired, step: PropTypes.number.isRequired }; export const TourExplanation = ({ topStyle, leftStyle, caretOffsetTop, caretOffsetLeft, indicatorOffsetTop, indicatorOffsetLeft, setTourIndex, onTourIndicatorClick, onPrevious, onNext, onSkip, currentStep, maxSteps, active, isFirstTourPoint, isLastTourPoint, children }) => { const { t } = useTranslation(); const theme = useTheme(); if (!active) { // Tour explanation requires the various positioning even if only just // showing the "tour indicator" button, as it is offset against the caret // which is offset against the original box return ( <Box position="absolute" style={{ zIndex: TourExplanationBoxZIndex - 1, top: topStyle, left: leftStyle }} > <Box position="absolute" style={{ top: `${caretOffsetTop}px`, left: `${caretOffsetLeft}px` }} > <TourIndicator onClick={onTourIndicatorClick} style={{ top: `${indicatorOffsetTop}px`, left: `${indicatorOffsetLeft}px` }} /> </Box> </Box> ); } return ( <TourExplanationBox paddedRatio={3} column style={{ top: topStyle, left: leftStyle }} > <CloseButton color={theme.darkWithOverlay} topRight onClick={() => onSkip?.()} /> <Spacing bottom={2} /> <Caret style={{ top: `${caretOffsetTop}px`, left: `${caretOffsetLeft}px` }} /> <Text light medium textDarker> <Text light medium noFontSize textDarker> {children} </Text> <Spacing bottom={3} /> <Box centered justifySpaceBetween> <TourProgress setTourIndex={setTourIndex} step={currentStep} max={maxSteps} /> <Box centered> {!isFirstTourPoint && ( <> <Button secondary shortMinHeight onClick={() => onPrevious?.()}> {t("general.back")} </Button> <Spacing right={2} /> </> )} {isLastTourPoint ? ( <Button primary shortMinHeight onClick={() => onSkip?.()}> {t("tour.finish")} </Button> ) : ( <Button primary shortMinHeight onClick={() => onNext?.()}> {t("general.next")} </Button> )} </Box> </Box> </Text> </TourExplanationBox> ); }; TourExplanation.propTypes = { children: PropTypes.node.isRequired, currentStep: PropTypes.number.isRequired, maxSteps: PropTypes.number.isRequired, caretOffsetTop: PropTypes.number, caretOffsetLeft: PropTypes.number, indicatorOffsetTop: PropTypes.number, indicatorOffsetLeft: PropTypes.number, setTourIndex: PropTypes.func.isRequired, onTourIndicatorClick: PropTypes.func.isRequired, onPrevious: PropTypes.func.isRequired, onNext: PropTypes.func.isRequired, onSkip: PropTypes.func.isRequired, topStyle: PropTypes.string, leftStyle: PropTypes.string, isFirstTourPoint: PropTypes.bool.isRequired, isLastTourPoint: PropTypes.bool.isRequired, active: PropTypes.bool }; const TourGrouping = observer(({ tourPoints }) => { const { i18n } = useTranslation(); const viewState = useViewState(); const currentTourPoint = tourPoints[viewState.currentTourIndex]; const currentTourPointRef = viewState.appRefs.get( currentTourPoint?.appRefName ); const currentRectangle = currentTourPointRef?.current?.getBoundingClientRect?.(); if (!currentRectangle) { console.log( "Tried to show guidance portal with no rectangle available from ref" ); } return ( <> {currentRectangle && ( <TourOverlay rectangle={currentRectangle} onCancel={() => viewState.nextTourPoint()} /> )} {tourPoints.map((tourPoint, index) => { const tourPointRef = viewState.appRefs.get(tourPoint?.appRefName); const currentRectangle = tourPointRef?.current?.getBoundingClientRect?.(); const { offsetTop, offsetLeft, caretOffsetTop, caretOffsetLeft, indicatorOffsetTop, indicatorOffsetLeft } = getOffsetsFromTourPoint(tourPoint); // To match old HelpScreenWindow / HelpOverlay API const currentScreen = { rectangle: currentRectangle, positionTop: tourPoint?.positionTop || viewState.relativePosition.RECT_BOTTOM, positionLeft: tourPoint?.positionLeft || viewState.relativePosition.RECT_LEFT, offsetTop: offsetTop, offsetLeft: offsetLeft }; const positionLeft = calculateLeftPosition(currentScreen); const positionTop = calculateTopPosition(currentScreen); const currentTourIndex = viewState.currentTourIndex; const maxSteps = tourPoints.length; if (!tourPoint) return null; return ( <TourExplanation key={tourPoint.appRefName} active={currentTourIndex === index} currentStep={currentTourIndex + 1} maxSteps={maxSteps} setTourIndex={(idx) => viewState.setTourIndex(idx)} onTourIndicatorClick={() => viewState.setTourIndex(index)} onPrevious={() => viewState.previousTourPoint()} onNext={() => viewState.nextTourPoint()} onSkip={() => viewState.closeTour()} isFirstTourPoint={index === 0} isLastTourPoint={index === tourPoints.length - 1} topStyle={`${positionTop}px`} leftStyle={`${positionLeft}px`} caretOffsetTop={caretOffsetTop} caretOffsetLeft={caretOffsetLeft} indicatorOffsetTop={indicatorOffsetTop} indicatorOffsetLeft={indicatorOffsetLeft} > {parseCustomMarkdownToReactWithOptions( applyTranslationIfExists(tourPoint?.content, i18n), { injectTermsAsTooltips: true, tooltipTerms: viewState.terria.configParameters.helpContentTerms } )} </TourExplanation> ); })} </> ); }); export const TourPreface = () => { const { t } = useTranslation(); const viewState = useViewState(); const theme = useTheme(); return ( <> <TourPrefaceBox onClick={() => viewState.closeTour()} role="presentation" aria-hidden="true" pseudoBg /> <TourExplanationBox longer paddedRatio={4} column style={{ right: 25, bottom: 45 }} > <CloseButton color={theme.darkWithOverlay} // color={"green"} topRight onClick={() => viewState.closeTour()} /> <Spacing bottom={2} /> <Text extraExtraLarge bold textDarker> {t("tour.preface.title")} </Text> <Spacing bottom={3} /> <Text light medium textDarker> {t("tour.preface.content")} </Text> <Spacing bottom={4} /> <Text medium> <Box> <Button fullWidth secondary onClick={(e) => { e.stopPropagation(); viewState.closeTour(); }} > {t("tour.preface.close")} </Button> <Spacing right={3} /> <Button primary fullWidth textProps={{ noFontSize: true }} onClick={(e) => { e.stopPropagation(); viewState.setShowTour(true); }} > {t("tour.preface.start")} </Button> </Box> </Text> <Spacing bottom={1} /> </TourExplanationBox> </> ); }; export const TourPortalDisplayName = "TourPortal"; export const TourPortal = observer(() => { const viewState = useViewState(); const showPortal = viewState.currentTourIndex !== -1; const showPreface = showPortal && !viewState.showTour; // should we bump up the debounce here? feels like 16ms is quite aggressive // and almost to the point of not debouncing at all, but the render logic // is quite cheap so it makes for a better resizing/zooming experience const width = useWindowSize({ debounceOverride: 16 }); useEffect(() => autorun(() => { if (showPortal && viewState.topElement !== TourPortalDisplayName) { viewState.setTopElement(TourPortalDisplayName); } }) ); if (viewState.useSmallScreenInterface || !showPortal) { return null; } if (showPreface) { return <TourPreface viewState={viewState} />; } return ( <TourGrouping key={width} viewState={viewState} tourPoints={viewState.tourPointsWithValidRefs} /> ); }); TourPortal.propTypes = { children: PropTypes.node }; export default withTheme(TourPortal);