terriajs
Version:
Geospatial data visualization platform.
390 lines (375 loc) • 11.5 kB
JSX
/**
* 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);