import { action, computed, makeObservable, observable, runInAction, toJS } from "mobx"; import { observer } from "mobx-react"; import { RefObject, ComponentPropsWithoutRef, FC, ReactNode, ReactElement, createRef, Component } from "react"; import Sortable from "react-anything-sortable"; import { Trans, WithTranslation, useTranslation, withTranslation } from "react-i18next"; import styled, { DefaultTheme, withTheme } from "styled-components"; import combine from "terriajs-cesium/Source/Core/combine"; import createGuid from "terriajs-cesium/Source/Core/createGuid"; import dataStoriesImg from "../../../wwwroot/images/data-stories-getting-started.jpg"; import { Category, StoryAction } from "../../Core/Analytics/analyticEvents"; import triggerResize from "../../Core/triggerResize"; import ViewState from "../../ReactViewModels/ViewState"; import Box from "../../Styled/Box"; import Button, { RawButton } from "../../Styled/Button"; import Icon, { StyledIcon } from "../../Styled/Icon"; import Spacing from "../../Styled/Spacing"; import Text, { TextSpan } from "../../Styled/Text"; import { WithViewState, withViewState } from "../Context"; import measureElement, { MeasureElementProps } from "../HOCs/measureElement"; import VideoGuide from "../Map/Panels/HelpPanel/VideoGuide"; import { getShareData } from "../Map/Panels/SharePanel/BuildShareLink"; import SharePanel from "../Map/Panels/SharePanel/SharePanel"; import Story from "./Story"; import StoryEditor from "./StoryEditor"; import Styles from "./story-builder.scss"; const STORY_VIDEO = "storyVideo"; export type StoryData = ViewState["terria"]["stories"][number]; interface IProps { isVisible?: boolean; animationDuration?: number; theme: DefaultTheme; } interface IState { editingMode: boolean; currentStory: StoryData | undefined; recaptureSuccessId: StoryData["id"] | undefined; clearRecaptureSuccessTimeout: (() => void) | undefined; showVideoGuide: boolean; isRemoving: boolean; isSharing: boolean; storyToRemove: StoryData | undefined; storyRemoveIndex: number | undefined; storyWithOpenMenuId: StoryData["id"] | undefined; } @observer class StoryBuilder extends Component< IProps & MeasureElementProps & WithTranslation & WithViewState, IState > { storiesWrapperRef = createRef(); refToMeasure: any; clearRecaptureSuccessTimeout?: () => void; constructor( props: IProps & MeasureElementProps & WithTranslation & WithViewState ) { super(props); makeObservable(this); this.state = { editingMode: false, currentStory: undefined, recaptureSuccessId: undefined, clearRecaptureSuccessTimeout: undefined, showVideoGuide: false, isRemoving: false, isSharing: false, storyToRemove: undefined, storyRemoveIndex: undefined, storyWithOpenMenuId: undefined }; } removeStory = (index: number, story?: StoryData) => { this.setState({ isSharing: false, isRemoving: true, storyToRemove: story, storyRemoveIndex: index }); }; @action.bound removeAction() { if (this.state.storyToRemove && this.state.storyRemoveIndex !== undefined) { this.props.viewState.terria.stories = this.props.viewState.terria.stories.filter( (st) => st.id !== this.state.storyToRemove!.id ); if (this.state.storyRemoveIndex < this.props.viewState.currentStoryId) { this.props.viewState.currentStoryId -= 1; } } else { this.removeAllStories(); } this.setState({ storyToRemove: undefined, storyRemoveIndex: undefined }); } toggleRemoveDialog = () => { this.setState({ isSharing: false, isRemoving: !this.state.isRemoving, storyToRemove: undefined, storyRemoveIndex: undefined }); }; @action.bound removeAllStories() { this.props.viewState.terria.stories = []; } @action.bound onSave(_story: StoryData) { const story = { title: _story.title, text: _story.text, id: _story.id ? _story.id : createGuid() }; this.props.viewState.terria.analytics.logEvent( Category.story, StoryAction.saveStory, JSON.stringify(story) ); const storyIndex = (this.props.viewState.terria.stories || []).findIndex( (story) => story.id === _story.id ); if (storyIndex >= 0) { const oldStory = this.props.viewState.terria.stories[storyIndex]; // replace the old story, we need to replace the stories array so that // it is observable this.props.viewState.terria.stories = [ ...this.props.viewState.terria.stories.slice(0, storyIndex), combine(story, oldStory), ...this.props.viewState.terria.stories.slice(storyIndex + 1) ]; } else { this.captureStory(story); } this.setState({ editingMode: false }); } @action captureStory( story: Omit & { shareData?: StoryData["shareData"] } ) { const shareData = toJS( getShareData(this.props.viewState.terria, this.props.viewState, { includeStories: false }) ); this.props.viewState.terria.stories.push({ ...story, shareData }); } @action recaptureScene(story: StoryData) { const { t } = this.props; this.closeShareRemoving(); this.clearRecaptureSuccessTimeout?.(); const storyIndex = (this.props.viewState.terria.stories || []).findIndex( (st) => st.id === story.id ); if (storyIndex >= 0) { story.shareData = JSON.parse( JSON.stringify( getShareData(this.props.viewState.terria, this.props.viewState, { includeStories: false }) ) ); this.props.viewState.terria.stories = [ ...this.props.viewState.terria.stories.slice(0, storyIndex), story, ...this.props.viewState.terria.stories.slice(storyIndex + 1) ]; this.setState({ recaptureSuccessId: story.id }); const timeout = setTimeout(this.resetReCaptureStatus, 2000); this.clearRecaptureSuccessTimeout = () => clearTimeout(timeout); } else { throw new Error(t("story.doesNotExist")); } } resetReCaptureStatus = () => { this.setState({ recaptureSuccessId: undefined }); }; closeShareRemoving = () => { this.setState({ isRemoving: false, isSharing: false }); }; runStories = () => { this.closeShareRemoving(); this.props.viewState.runStories(); }; @action editStory(story: StoryData) { this.closeShareRemoving(); this.props.viewState.storyShown = false; this.setState({ editingMode: true, currentStory: story }); } @action viewStory(index: number) { this.closeShareRemoving(); this.props.viewState.currentStoryId = index; this.runStories(); } @action.bound onSort( sortedArray: StoryData[], _currentDraggingSortData: any, _currentDraggingIndex: any ) { this.props.viewState.terria.stories = sortedArray; } @observable viewState: ViewState | undefined; @computed get shareDataStringSize() { if (!this.viewState) return undefined; const terria = this.viewState.terria; const stories = terria.stories; const validStories = stories.filter( (story) => story.shareData.initSources.length > 0 ).length; return JSON.stringify( getShareData(terria, this.viewState, { includeStories: validStories > 0 }) ).length; } componentDidMount() { runInAction(() => { this.viewState = this.props.viewState; }); } componentDidUpdate(prevProps: { viewState: ViewState }) { if (prevProps.viewState !== this.props.viewState) { runInAction(() => { this.viewState = this.props.viewState; }); } } componentWillUnmount() { this.clearRecaptureSuccessTimeout?.(); } renderIntro() { const { t } = this.props; return ( { this.props.viewState.setVideoGuideVisible(STORY_VIDEO); }} > ); } toggleSharePanel = () => { this.setState({ isRemoving: false, isSharing: !this.state.isSharing }); }; renderPlayShare() { const { t } = this.props; return ( ); } openMenu(storyId: StoryData["id"] | undefined) { this.setState({ storyWithOpenMenuId: storyId }); } renderStories() { const { t, i18n } = this.props; const stories = this.props.viewState.terria.stories || []; const storyName = this.state.storyToRemove ? this.state.storyToRemove.title.length ? this.state.storyToRemove.title : t("story.untitledScene") : ""; return ( <> {t("story.badgeBarLabel")}{" "} {`(${this.props.viewState.terria.stories.length})`} {t("story.removeAllStories")} {this.props.viewState.terria.configParameters .showStorySaveInstructions && ( )} {this.state.isRemoving && ( Are you sure you wish to delete {/* @ts-expect-error i18next won't properly interpolate text if not in double brackets({{ }}) */} {{ storyName }} ? ) : ( {t("story.removeAllStoriesDialog", { count: this.props.viewState.terria.stories.length })} ) } onConfirm={this.removeAction} closeDialog={this.toggleRemoveDialog} /> )} } css={` min-height: 130px; margin-right: -10px; `} > {stories.map((story, index) => ( this.removeStory(index, story)} recaptureStory={() => this.recaptureScene(story)} recaptureStorySuccessful={Boolean( story.id === this.state.recaptureSuccessId )} viewStory={() => this.viewStory(index)} menuOpen={this.state.storyWithOpenMenuId === story.id} openMenu={() => this.openMenu(story.id)} closeMenu={() => this.openMenu(undefined)} editStory={() => this.editStory(story)} parentRef={this.storiesWrapperRef} index={index} /> ))} ); } onClickCapture = () => { this.setState({ editingMode: true, currentStory: undefined }); }; hideStoryBuilder = () => { this.props.viewState.toggleStoryBuilder(); this.props.viewState.terria.currentViewer.notifyRepaintRequired(); // Allow any animations to finish, then trigger a resize. setTimeout(function () { triggerResize(); }, this.props.animationDuration || 1); this.props.viewState.toggleFeaturePrompt("story", false, true); }; render() { const { t } = this.props; const hasStories = this.props.viewState.terria.stories.length > 0; const shareDataSize = this.shareDataStringSize; const shareMaxRequestSize = this.props.viewState.terria.shareDataService?.shareMaxRequestSize; const shareMaxRequestSizeBytes = this.props.viewState.terria.shareDataService?.shareMaxRequestSizeBytes; // Disable the warning if map owners use custom server that does not return shareMaxRequestSize: const shareDataTooLong = shareDataSize && shareMaxRequestSizeBytes ? shareDataSize > shareMaxRequestSizeBytes : false; return ( (this.refToMeasure = component)} isVisible={this.props.isVisible} isHidden={!this.props.isVisible} styledWidth={"320px"} styledMinWidth={"320px"} backgroundColor={this.props.theme.dark} column > {t("story.panelTitle")} {`${t("story.panelBody")}${ shareMaxRequestSize ? ` ${t("story.panelBodyCapped", { shareMaxRequestSize })}` : "" }`} {!hasStories && this.renderIntro()} {hasStories && this.renderPlayShare()} {shareDataTooLong && ( {t("story.storiesTooLong")} )} {hasStories && this.renderStories()} {this.state.editingMode && ( this.setState({ editingMode: false })} story={this.state.currentStory} saveStory={this.onSave} terria={this.props.viewState.terria} /> )} ); } } type PanelProps = ComponentPropsWithoutRef & { isVisible?: boolean; isHidden?: boolean; }; const Panel = styled(Box)` transition: all 0.25s; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); width: 320px; min-width: 320px; height: 100vh; ${(props) => props.isVisible && ` visibility: visible; margin-right: 0; `} ${(props) => props.isHidden && ` visibility: hidden; margin-right: -100%; `} `; interface CaptureSceneProps { onClickCapture: () => void; disabled?: boolean; } const CaptureScene: FC = (props) => { const { t } = useTranslation(); return ( ); }; type StoryButtonProps = ComponentPropsWithoutRef & { btnText: string; children: ReactNode; }; export const StoryButton: FC = (props) => { const { btnText, ...rest } = props; return ( ); }; interface RemoveDialogProps { theme: DefaultTheme; text: ReactElement; onConfirm: () => void; closeDialog: () => void; } const RemoveDialog: FC = (props) => { const { t } = useTranslation(); return ( {props.text} ); }; export default withViewState( withTranslation()(withTheme(measureElement(StoryBuilder))) );