diff --git a/gui/assets/css/global.css b/gui/assets/css/global.css index f80739cb4a18..f7adc6da4457 100644 --- a/gui/assets/css/global.css +++ b/gui/assets/css/global.css @@ -41,3 +41,14 @@ body { transition-duration: 0ms !important; } } + +::view-transition-image-pair(root) { + isolation: auto; +} + +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; + display: block; +} diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 6f65034f57ec..55a6b7a44501 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -44,7 +44,7 @@ import KeyboardNavigation from './components/KeyboardNavigation'; import Lang from './components/Lang'; import MacOsScrollbarDetection from './components/MacOsScrollbarDetection'; import { AppContext } from './context'; -import History, { ITransitionSpecification, transitions } from './lib/history'; +import History, { TransitionType } from './lib/history'; import { loadTranslations } from './lib/load-translations'; import IpcOutput from './lib/logging'; import { RoutePath } from './lib/routes'; @@ -399,7 +399,7 @@ export default class AppRenderer { actions.account.loginTooManyDevices(); this.loginState = 'too many devices'; - this.history.reset(RoutePath.tooManyDevices, { transition: transitions.push }); + this.history.reset(RoutePath.tooManyDevices, { transition: TransitionType.push }); } catch { log.error('Failed to fetch device list'); actions.account.loginFailed('list-devices'); @@ -416,7 +416,7 @@ export default class AppRenderer { this.loginState = 'none'; }; - public logout = async (transition = transitions.dismiss) => { + public logout = async (transition = TransitionType.dismiss) => { try { this.history.reset(RoutePath.login, { transition }); await IpcRendererEventChannel.account.logout(); @@ -427,7 +427,7 @@ export default class AppRenderer { }; public leaveRevokedDevice = async () => { - await this.logout(transitions.pop); + await this.logout(TransitionType.pop); await this.disconnectTunnel(); }; @@ -708,38 +708,38 @@ export default class AppRenderer { // First level contains the possible next locations and the second level contains the // possible current locations. const navigationTransitions: Partial< - Record>> + Record>> > = { [RoutePath.launch]: { - [RoutePath.login]: transitions.pop, - [RoutePath.main]: transitions.pop, - '*': transitions.dismiss, + [RoutePath.login]: TransitionType.pop, + [RoutePath.main]: TransitionType.pop, + '*': TransitionType.dismiss, }, [RoutePath.login]: { - [RoutePath.launch]: transitions.push, - [RoutePath.main]: transitions.pop, - [RoutePath.deviceRevoked]: transitions.pop, - '*': transitions.dismiss, + [RoutePath.launch]: TransitionType.push, + [RoutePath.main]: TransitionType.pop, + [RoutePath.deviceRevoked]: TransitionType.pop, + '*': TransitionType.dismiss, }, [RoutePath.main]: { - [RoutePath.launch]: transitions.push, - [RoutePath.login]: transitions.push, - [RoutePath.tooManyDevices]: transitions.push, - '*': transitions.dismiss, + [RoutePath.launch]: TransitionType.push, + [RoutePath.login]: TransitionType.push, + [RoutePath.tooManyDevices]: TransitionType.push, + '*': TransitionType.dismiss, }, [RoutePath.expired]: { - [RoutePath.launch]: transitions.push, - [RoutePath.login]: transitions.push, - [RoutePath.tooManyDevices]: transitions.push, - '*': transitions.dismiss, + [RoutePath.launch]: TransitionType.push, + [RoutePath.login]: TransitionType.push, + [RoutePath.tooManyDevices]: TransitionType.push, + '*': TransitionType.dismiss, }, [RoutePath.timeAdded]: { - [RoutePath.expired]: transitions.push, - [RoutePath.redeemVoucher]: transitions.push, - '*': transitions.dismiss, + [RoutePath.expired]: TransitionType.push, + [RoutePath.redeemVoucher]: TransitionType.push, + '*': TransitionType.dismiss, }, [RoutePath.deviceRevoked]: { - '*': transitions.pop, + '*': TransitionType.pop, }, }; diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index 75b8df936c3b..8a5b471a97c9 100644 --- a/gui/src/renderer/components/AppRouter.tsx +++ b/gui/src/renderer/components/AppRouter.tsx @@ -1,10 +1,11 @@ -import { createRef, useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { flushSync } from 'react-dom'; import { Route, Switch } from 'react-router'; import SelectLocation from '../components/select-location/SelectLocationContainer'; import LoginPage from '../containers/LoginPage'; import { useAppContext } from '../context'; -import { ITransitionSpecification, transitions, useHistory } from '../lib/history'; +import { TransitionType, useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; import Account from './Account'; import ApiAccessMethods from './ApiAccessMethods'; @@ -21,7 +22,6 @@ import { } from './ExpiredAccountAddTime'; import ExpiredAccountErrorView from './ExpiredAccountErrorView'; import Filter from './Filter'; -import Focus, { IFocusHandle } from './Focus'; import Launch from './Launch'; import MainView from './main-view/MainView'; import OpenVpnSettings from './OpenVpnSettings'; @@ -34,7 +34,6 @@ import Shadowsocks from './Shadowsocks'; import SplitTunnelingSettings from './SplitTunnelingSettings'; import Support from './Support'; import TooManyDevices from './TooManyDevices'; -import TransitionContainer, { TransitionView } from './TransitionContainer'; import UdpOverTcp from './UdpOverTcp'; import UserInterfaceSettings from './UserInterfaceSettings'; import VpnSettings from './VpnSettings'; @@ -43,9 +42,7 @@ import WireguardSettings from './WireguardSettings'; export default function AppRouter() { const history = useHistory(); const [currentLocation, setCurrentLocation] = useState(history.location); - const [transition, setTransition] = useState(transitions.none); const { setNavigationHistory } = useAppContext(); - const focusRef = createRef(); let unobserveHistory: () => void; @@ -53,57 +50,107 @@ export default function AppRouter() { // React throttles updates, so it's impossible to capture the intermediate navigation without // listening to the history directly. unobserveHistory = history.listen((location, _, transition) => { - setNavigationHistory(history.asObject); - setCurrentLocation(location); - setTransition(transition); + flushSync(() => { + document + // @ts-ignore + .startViewTransition(() => { + setNavigationHistory(history.asObject); + setCurrentLocation(location); + }) + .ready.then(() => animateNavigation(transition)); + }); }); return () => unobserveHistory?.(); }, []); - const onNavigation = useCallback(() => { - focusRef.current?.resetFocus(); - }, []); - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function animateNavigation(transition: TransitionType) { + const oldZIndex = + transition === TransitionType.dismiss || transition === TransitionType.pop ? 100 : undefined; + document.documentElement.animate( + [ + { transform: 'translate(0%, 0%)', zIndex: oldZIndex }, + { transform: oldToTransform(transition), zIndex: oldZIndex }, + ], + { + duration: 450, + easing: 'ease-in-out', + pseudoElement: '::view-transition-old(root)', + }, ); + document.documentElement.animate( + [{ transform: newFromTransform(transition) }, { transform: 'translate(0%, 0%)' }], + { + duration: 450, + easing: 'ease-in-out', + pseudoElement: '::view-transition-new(root)', + }, + ); +} + +function oldToTransform(transition: TransitionType): string { + switch (transition) { + case TransitionType.show: + return 'translateY(0%)'; + case TransitionType.dismiss: + return 'translateY(100%)'; + case TransitionType.push: + return 'translateX(-50%)'; + case TransitionType.pop: + return 'translateX(100%)'; + case TransitionType.none: + return ''; + } +} + +function newFromTransform(transition: TransitionType): string { + switch (transition) { + case TransitionType.show: + return 'translateY(100%)'; + case TransitionType.dismiss: + return 'translateY(0%)'; + case TransitionType.push: + return 'translateX(100%)'; + case TransitionType.pop: + return 'translateX(-50%)'; + case TransitionType.none: + return ''; + } } diff --git a/gui/src/renderer/components/ExpiredAccountAddTime.tsx b/gui/src/renderer/components/ExpiredAccountAddTime.tsx index 3b54642978e1..b43e5e2168a5 100644 --- a/gui/src/renderer/components/ExpiredAccountAddTime.tsx +++ b/gui/src/renderer/components/ExpiredAccountAddTime.tsx @@ -9,7 +9,7 @@ import { formatRelativeDate } from '../../shared/date-helper'; import { messages } from '../../shared/gettext'; import { useAppContext } from '../context'; import useActions from '../lib/actionsHook'; -import { transitions, useHistory } from '../lib/history'; +import { TransitionType, useHistory } from '../lib/history'; import { generateRoutePath } from '../lib/routeHelpers'; import { RoutePath } from '../lib/routes'; import account from '../redux/account/actions'; @@ -283,7 +283,7 @@ function useFinishedCallback() { accountSetupFinished(); } - history.reset(RoutePath.main, { transition: transitions.push }); + history.reset(RoutePath.main, { transition: TransitionType.push }); }, [isNewAccount, accountSetupFinished, history]); return callback; diff --git a/gui/src/renderer/components/Focus.tsx b/gui/src/renderer/components/Focus.tsx deleted file mode 100644 index c29cbe653ea5..000000000000 --- a/gui/src/renderer/components/Focus.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useImperativeHandle, useState } from 'react'; -import { useLocation } from 'react-router'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; - -import { messages } from '../../shared/gettext'; - -const FOCUS_FALLBACK_CLASS = 'focus-fallback'; - -const PageChangeAnnouncer = styled.div({ - width: 0, - height: 0, - overflow: 'hidden', -}); - -export interface IFocusHandle { - resetFocus(): void; -} - -interface IFocusProps { - children?: React.ReactElement; -} - -function Focus(props: IFocusProps, ref: React.Ref) { - const location = useLocation(); - const [title, setTitle] = useState(); - - useImperativeHandle( - ref, - () => ({ - resetFocus: () => { - const pageName = location.pathname.slice(location.pathname.lastIndexOf('/') + 1); - const titleElement = document.getElementsByTagName('h1')[0]; - const titleContent = titleElement?.textContent ?? pageName; - setTitle(titleContent); - - const focusElement = - titleElement ?? document.getElementsByClassName(FOCUS_FALLBACK_CLASS)[0]; - if (focusElement) { - focusElement.setAttribute('tabindex', '-1'); - focusElement.focus(); - } - }, - }), - [location.pathname], - ); - - return ( - <> - {title && ( - - { - // TRANSLATORS: This string is used to notify users of screenreaders that the view has - // TRANSLATORS: changed, usually as a result of pressing a navigation button. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(title)s - page title - sprintf(messages.pgettext('accessibility', '%(title)s, View loaded'), { title }) - } - - )} - {props.children} - - ); -} - -export default React.memo(React.forwardRef(Focus)); - -interface IFocusFallbackProps { - children: React.ReactElement; -} - -export function FocusFallback(props: IFocusFallbackProps) { - return React.cloneElement(props.children, { - className: `${props.children.props.className} ${FOCUS_FALLBACK_CLASS}`, - }); -} diff --git a/gui/src/renderer/components/HeaderBar.tsx b/gui/src/renderer/components/HeaderBar.tsx index d0f2bde6efe9..37ff2605d3c1 100644 --- a/gui/src/renderer/components/HeaderBar.tsx +++ b/gui/src/renderer/components/HeaderBar.tsx @@ -7,11 +7,10 @@ import { closeToExpiry, formatRemainingTime, hasExpired } from '../../shared/acc import { TunnelState } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { capitalizeEveryWord } from '../../shared/string-helpers'; -import { transitions, useHistory } from '../lib/history'; +import { TransitionType, useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; import { useSelector } from '../redux/store'; import { tinyText } from './common-styles'; -import { FocusFallback } from './Focus'; import ImageView from './ImageView'; export enum HeaderBarStyle { @@ -176,7 +175,7 @@ export function HeaderBarSettingsButton(props: IHeaderBarSettingsButtonProps) { const openSettings = useCallback(() => { if (!props.disabled) { - history.push(RoutePath.settings, { transition: transitions.show }); + history.push(RoutePath.settings, { transition: TransitionType.show }); } }, [history, props.disabled]); @@ -198,7 +197,7 @@ export function HeaderBarSettingsButton(props: IHeaderBarSettingsButtonProps) { export function HeaderBarAccountButton() { const history = useHistory(); const openAccount = useCallback( - () => history.push(RoutePath.account, { transition: transitions.show }), + () => history.push(RoutePath.account, { transition: TransitionType.show }), [history], ); @@ -223,9 +222,7 @@ export function DefaultHeaderBar(props: IHeaderBarProps) { return ( - - - + {loggedIn && } diff --git a/gui/src/renderer/components/Launch.tsx b/gui/src/renderer/components/Launch.tsx index 05bf6aeda60e..a2e0db65ba90 100644 --- a/gui/src/renderer/components/Launch.tsx +++ b/gui/src/renderer/components/Launch.tsx @@ -4,7 +4,7 @@ import styled from 'styled-components'; import { colors } from '../../config.json'; import { messages } from '../../shared/gettext'; import { useAppContext } from '../context'; -import { transitions, useHistory } from '../lib/history'; +import { TransitionType, useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; import { useBoolean } from '../lib/utilityHooks'; import { useSelector } from '../redux/store'; @@ -77,7 +77,7 @@ function DefaultFooter() { const openSendProblemReport = useCallback(() => { hideDialog(); - history.push(RoutePath.problemReport, { transition: transitions.show }); + history.push(RoutePath.problemReport, { transition: TransitionType.show }); }, [hideDialog, history.push]); return ( diff --git a/gui/src/renderer/components/NavigationBar.tsx b/gui/src/renderer/components/NavigationBar.tsx index ee316de78331..dee3f368717e 100644 --- a/gui/src/renderer/components/NavigationBar.tsx +++ b/gui/src/renderer/components/NavigationBar.tsx @@ -4,7 +4,7 @@ import styled from 'styled-components'; import { colors } from '../../config.json'; import { messages } from '../../shared/gettext'; import { useAppContext } from '../context'; -import { transitions, useHistory } from '../lib/history'; +import { TransitionType, useHistory } from '../lib/history'; import { useCombinedRefs } from '../lib/utilityHooks'; import CustomScrollbars, { CustomScrollbarsRef, IScrollEvent } from './CustomScrollbars'; import InfoButton from './InfoButton'; @@ -189,7 +189,7 @@ export function BackBarItem() { const history = useHistory(); // Compare the transition name with dismiss to infer wheter or not the view will slide // horizontally or vertically and then use matching button. - const backIcon = useMemo(() => history.getPopTransition().name !== transitions.dismiss.name, []); + const backIcon = useMemo(() => history.getPopTransition() !== TransitionType.dismiss, []); const { parentBackAction } = useContext(BackActionContext); const iconSource = backIcon ? 'icon-back' : 'icon-close-down'; const ariaLabel = backIcon ? messages.gettext('Back') : messages.gettext('Close'); diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx index fff30300d0b4..c196ea9fe743 100644 --- a/gui/src/renderer/components/NotificationArea.tsx +++ b/gui/src/renderer/components/NotificationArea.tsx @@ -19,7 +19,7 @@ import { } from '../../shared/notifications/notification'; import { useAppContext } from '../context'; import useActions from '../lib/actionsHook'; -import { transitions, useHistory } from '../lib/history'; +import { TransitionType, useHistory } from '../lib/history'; import { formatHtml } from '../lib/html-formatter'; import { RoutePath } from '../lib/routes'; import accountActions from '../redux/account/actions'; @@ -154,7 +154,7 @@ function NotificationActionWrapper(props: INotificationActionWrapperProps) { const goToProblemReport = useCallback(() => { setTroubleshootInfo(undefined); - history.push(RoutePath.problemReport, { transition: transitions.show }); + history.push(RoutePath.problemReport, { transition: TransitionType.show }); }, []); const closeTroubleshootInfo = useCallback(() => setTroubleshootInfo(undefined), []); diff --git a/gui/src/renderer/components/SettingsImport.tsx b/gui/src/renderer/components/SettingsImport.tsx index d93064ff2e36..ad49407de30f 100644 --- a/gui/src/renderer/components/SettingsImport.tsx +++ b/gui/src/renderer/components/SettingsImport.tsx @@ -7,7 +7,7 @@ import { messages } from '../../shared/gettext'; import { useScheduler } from '../../shared/scheduler'; import { useAppContext } from '../context'; import useActions from '../lib/actionsHook'; -import { transitions, useHistory } from '../lib/history'; +import { TransitionType, useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; import { useAsyncEffect, useBoolean } from '../lib/utilityHooks'; import settingsImportActions from '../redux/settings-import/actions'; @@ -88,7 +88,7 @@ export default function SettingsImport() { }, []); const navigateTextImport = useCallback(() => { - history.push(RoutePath.settingsTextImport, { transition: transitions.show }); + history.push(RoutePath.settingsTextImport, { transition: TransitionType.show }); }, [history]); const importFile = useCallback(async () => { diff --git a/gui/src/renderer/components/TooManyDevices.tsx b/gui/src/renderer/components/TooManyDevices.tsx index cbd9e2522e3b..9db57140e9ea 100644 --- a/gui/src/renderer/components/TooManyDevices.tsx +++ b/gui/src/renderer/components/TooManyDevices.tsx @@ -8,7 +8,7 @@ import { messages } from '../../shared/gettext'; import log from '../../shared/logging'; import { capitalizeEveryWord } from '../../shared/string-helpers'; import { useAppContext } from '../context'; -import { transitions, useHistory } from '../lib/history'; +import { TransitionType, useHistory } from '../lib/history'; import { formatHtml } from '../lib/html-formatter'; import { RoutePath } from '../lib/routes'; import { useBoolean } from '../lib/utilityHooks'; @@ -108,11 +108,11 @@ export default function TooManyDevices() { const continueLogin = useCallback(() => { void login(accountToken); - history.reset(RoutePath.login, { transition: transitions.pop }); + history.reset(RoutePath.login, { transition: TransitionType.pop }); }, [login, accountToken]); const cancel = useCallback(() => { cancelLogin(); - history.reset(RoutePath.login, { transition: transitions.pop }); + history.reset(RoutePath.login, { transition: TransitionType.pop }); }, [history.reset, cancelLogin]); const iconSource = getIconSource(devices); diff --git a/gui/src/renderer/components/TransitionContainer.tsx b/gui/src/renderer/components/TransitionContainer.tsx deleted file mode 100644 index 1df9b097c507..000000000000 --- a/gui/src/renderer/components/TransitionContainer.tsx +++ /dev/null @@ -1,381 +0,0 @@ -import * as React from 'react'; -import styled from 'styled-components'; - -import { ITransitionSpecification } from '../lib/history'; -import { WillExit } from '../lib/will-exit'; - -interface ITransitioningViewProps { - routePath: string; - children?: React.ReactNode; -} - -type TransitioningView = React.ReactElement; - -interface ITransitionQueueItem { - view: TransitioningView; - transition: ITransitionSpecification; -} - -interface IProps extends ITransitionSpecification { - children: TransitioningView; - onTransitionEnd: () => void; -} - -interface IItemStyle { - // x and y are percentages - x: number; - y: number; - inFront: boolean; - duration?: number; -} - -interface IState { - currentItem?: ITransitionQueueItem; - nextItem?: ITransitionQueueItem; - queuedItem?: ITransitionQueueItem; - currentItemStyle?: IItemStyle; - nextItemStyle?: IItemStyle; - currentItemTransition?: Partial; - nextItemTransition?: Partial; -} - -export const StyledTransitionContainer = styled.div({ flex: 1 }); - -interface StyledTransitionContentProps { - $transition?: IItemStyle; - $disableUserInteraction?: boolean; -} - -export const StyledTransitionContent = styled.div.attrs< - StyledTransitionContentProps, - { 'data-testid': string } ->({ - 'data-testid': 'transition-content', -})((props) => { - const x = `${props.$transition?.x ?? 0}%`; - const y = `${props.$transition?.y ?? 0}%`; - const duration = props.$transition?.duration ?? 450; - - return { - display: 'flex', - flexDirection: 'column', - position: 'absolute', - left: 0, - right: 0, - top: 0, - bottom: 0, - zIndex: props.$transition?.inFront ? 1 : 0, - willChange: 'transform', - transform: `translate3d(${x}, ${y}, 0)`, - transition: `transform ${duration}ms ease-in-out`, - pointerEvents: props.$disableUserInteraction ? 'none' : undefined, - }; -}); - -export const StyledTransitionView = styled.div({ - display: 'flex', - flex: 1, - flexDirection: 'column', - height: '100%', - width: '100%', -}); - -export class TransitionView extends React.Component { - public render() { - return ( - - {this.props.children} - - ); - } -} - -export default class TransitionContainer extends React.Component { - public state: IState = { - currentItem: TransitionContainer.makeItem(this.props), - }; - - private isCycling = false; - private isTransitioning = false; - - private currentContentRef: React.MutableRefObject = - React.createRef(); - private nextContentRef: React.MutableRefObject = - React.createRef(); - // The item that should trigger the cycle to finish in onTransitionEnd - private transitioningItemRef?: React.RefObject; - - public componentDidUpdate(prevProps: IProps) { - if (this.props.children !== prevProps.children) { - this.updateStateFromProps(); - } - - if ( - this.state.currentItemStyle && - this.state.currentItemTransition && - this.state.nextItemStyle && - this.state.nextItemTransition - ) { - // Force browser reflow before starting transition. Without this animations won't run since - // the next view content hasn't been painted yet. It will just appear without a transition. - void this.nextContentRef.current?.offsetHeight; - - // Start transition - this.setState((state) => ({ - currentItemStyle: Object.assign({}, state.currentItemStyle, state.currentItemTransition), - nextItemStyle: Object.assign({}, state.nextItemStyle, state.nextItemTransition), - currentItemTransition: undefined, - nextItemTransition: undefined, - })); - } else { - this.cycle(); - } - } - - public render() { - const willExit = this.state.queuedItem !== undefined || this.state.nextItem !== undefined; - - return ( - - {this.state.currentItem && ( - - - {this.state.currentItem.view} - - - )} - - {this.state.nextItem && ( - - - {this.state.nextItem.view} - - - )} - - ); - } - - private setCurrentContentRef = (element: HTMLDivElement) => { - this.currentContentRef.current?.removeEventListener('transitionstart', this.onTransitionStart); - this.currentContentRef.current = element; - this.currentContentRef.current?.addEventListener('transitionstart', this.onTransitionStart); - }; - - private setNextContentRef = (element: HTMLDivElement) => { - this.nextContentRef.current?.removeEventListener('transitionstart', this.onTransitionStart); - this.nextContentRef.current = element; - this.nextContentRef.current?.addEventListener('transitionstart', this.onTransitionStart); - }; - - private updateStateFromProps() { - const candidate = this.props.children; - - if (candidate && this.state.currentItem) { - // Update currentItem, nextItem, queuedItem depending on which the candidate matches. - if ( - !this.isCycling && - this.state.currentItem.view.props.routePath === candidate.props.routePath - ) { - // There's no transition in progress and the newest candidate has the same path as the - // current. In this situation the app should just remain in the same view. - this.setState( - { - currentItem: TransitionContainer.makeItem(this.props), - nextItem: undefined, - queuedItem: undefined, - currentItemStyle: undefined, - nextItemStyle: undefined, - currentItemTransition: undefined, - nextItemTransition: undefined, - }, - () => (this.isCycling = false), - ); - } else if (!this.isCycling && this.state.nextItem) { - // There's no transition in progress but there is a next item. Abort the transition and add - // the candidate to the queue. The app shouldn't start a transition if there is another view - // to queue. - this.setState( - { - nextItem: undefined, - queuedItem: TransitionContainer.makeItem(this.props), - currentItemStyle: undefined, - nextItemStyle: undefined, - currentItemTransition: undefined, - nextItemTransition: undefined, - }, - () => (this.isCycling = false), - ); - } else if (this.state.nextItem?.view.props.routePath === candidate.props.routePath) { - // There's an update to the item that is currently being transitioned to. Update that item - // and continue the transition. - this.setState({ - nextItem: TransitionContainer.makeItem(this.props), - queuedItem: undefined, - }); - } else { - // If none of the above, initiate a transition to the new item. - this.setState({ queuedItem: TransitionContainer.makeItem(this.props) }); - } - } else if (candidate) { - // Child is set as current item if there's no item already. - this.setState({ currentItem: TransitionContainer.makeItem(this.props) }); - } - } - - private onTransitionStart = (event: TransitionEvent) => { - if ( - this.isCycling && - !this.isTransitioning && - event.target === this.transitioningItemRef?.current - ) { - this.isTransitioning = true; - } - }; - - private onTransitionEnd = (event: React.TransitionEvent) => { - if (this.isCycling && event.target === this.transitioningItemRef?.current) { - this.isTransitioning = false; - this.transitioningItemRef = undefined; - this.makeNextItemCurrent(() => { - this.onFinishCycle(); - }); - } - }; - - private cycle() { - if (!this.isCycling) { - this.isCycling = true; - this.cycleUnguarded(); - } - } - - private onFinishCycle() { - this.props.onTransitionEnd(); - this.cycleUnguarded(); - } - - private cycleUnguarded = () => { - if (this.state.queuedItem) { - const transition = this.state.queuedItem.transition; - - switch (transition.name) { - case 'slide-up': - this.slideUp(transition.duration); - break; - - case 'slide-down': - this.slideDown(transition.duration); - break; - - case 'push': - this.push(transition.duration); - break; - - case 'pop': - this.pop(transition.duration); - break; - - default: - this.replace(() => this.onFinishCycle); - break; - } - } else { - this.isCycling = false; - } - }; - - private static makeItem(props: IProps): ITransitionQueueItem { - return { - transition: { - name: props.name, - duration: props.duration, - }, - view: React.cloneElement(props.children), - }; - } - - private makeNextItemCurrent(completion: () => void) { - this.setState( - (state) => ({ - currentItem: state.nextItem, - nextItem: undefined, - currentItemStyle: undefined, - nextItemStyle: undefined, - currentItemTransition: undefined, - nextItemTransition: undefined, - }), - completion, - ); - } - - private slideUp(duration: number) { - this.transitioningItemRef = this.nextContentRef; - this.setState((state) => ({ - nextItem: state.queuedItem, - queuedItem: undefined, - currentItemStyle: { x: 0, y: 0, inFront: false }, - nextItemStyle: { x: 0, y: 100, inFront: true }, - currentItemTransition: { duration }, - nextItemTransition: { y: 0, duration }, - })); - } - - private slideDown(duration: number) { - this.transitioningItemRef = this.currentContentRef; - this.setState((state) => ({ - nextItem: state.queuedItem, - queuedItem: undefined, - currentItemStyle: { x: 0, y: 0, inFront: true }, - nextItemStyle: { x: 0, y: 0, inFront: false }, - currentItemTransition: { y: 100, duration }, - nextItemTransition: { duration }, - })); - } - - private push(duration: number) { - this.transitioningItemRef = this.nextContentRef; - this.setState((state) => ({ - nextItem: state.queuedItem, - queuedItem: undefined, - currentItemStyle: { x: 0, y: 0, inFront: false }, - nextItemStyle: { x: 100, y: 0, inFront: true }, - currentItemTransition: { x: -50, duration }, - nextItemTransition: { x: 0, duration }, - })); - } - - private pop(duration: number) { - this.transitioningItemRef = this.currentContentRef; - this.setState((state) => ({ - nextItem: state.queuedItem, - queuedItem: undefined, - currentItemStyle: { x: 0, y: 0, inFront: true }, - nextItemStyle: { x: -50, y: 0, inFront: false }, - currentItemTransition: { x: 100, duration }, - nextItemTransition: { x: 0, duration }, - })); - } - - private replace(completion: () => void) { - this.setState( - (state) => ({ - currentItem: state.queuedItem, - nextItem: undefined, - queuedItem: undefined, - currentItemStyle: { x: 0, y: 0, inFront: false, duration: 0 }, - nextItemStyle: { x: 0, y: 0, inFront: true, duration: 0 }, - currentItemTransition: undefined, - nextItemTransition: undefined, - }), - completion, - ); - } -} diff --git a/gui/src/renderer/components/main-view/SelectLocationButton.tsx b/gui/src/renderer/components/main-view/SelectLocationButton.tsx index 30b132a84eeb..5daf847fc29f 100644 --- a/gui/src/renderer/components/main-view/SelectLocationButton.tsx +++ b/gui/src/renderer/components/main-view/SelectLocationButton.tsx @@ -6,7 +6,7 @@ import { ICustomList } from '../../../shared/daemon-rpc-types'; import { messages, relayLocations } from '../../../shared/gettext'; import log from '../../../shared/logging'; import { useAppContext } from '../../context'; -import { transitions, useHistory } from '../../lib/history'; +import { TransitionType, useHistory } from '../../lib/history'; import { RoutePath } from '../../lib/routes'; import { IRelayLocationCountryRedux, RelaySettingsRedux } from '../../redux/settings/reducers'; import { useSelector } from '../../redux/store'; @@ -46,7 +46,7 @@ function SelectLocationButton(props: MultiButtonCompatibleProps) { ); const onSelectLocation = useCallback(() => { - history.push(RoutePath.selectLocation, { transition: transitions.show }); + history.push(RoutePath.selectLocation, { transition: TransitionType.show }); }, [history.push]); return ( diff --git a/gui/src/renderer/lib/history.tsx b/gui/src/renderer/lib/history.tsx index 741c298da633..0aa29fb23b81 100644 --- a/gui/src/renderer/lib/history.tsx +++ b/gui/src/renderer/lib/history.tsx @@ -5,54 +5,32 @@ import { IHistoryObject, LocationState } from '../../shared/ipc-types'; import { GeneratedRoutePath } from './routeHelpers'; import { RoutePath } from './routes'; -export interface ITransitionSpecification { - name: string; - duration: number; +export enum TransitionType { + show, + dismiss, + push, + pop, + none, } -interface ITransitionMap { - [name: string]: ITransitionSpecification; +export interface ITransitionSpecification { + type: TransitionType; + duration: number; } -/** - * Transition descriptors - */ -export const transitions: ITransitionMap = { - show: { - name: 'slide-up', - duration: 450, - }, - dismiss: { - name: 'slide-down', - duration: 450, - }, - push: { - name: 'push', - duration: 450, - }, - pop: { - name: 'pop', - duration: 450, - }, - none: { - name: '', - duration: 0, - }, -}; - -const transitionOpposites: Record = { - 'slide-up': 'slide-down', - 'slide-down': 'slide-up', - push: 'pop', - pop: 'push', - '': '', -}; - -function oppositeTransition(transition: ITransitionSpecification): ITransitionSpecification { - return { - ...transition, - name: transitionOpposites[transition.name], - }; +function oppositeTransition(transition: TransitionType): TransitionType { + switch (transition) { + case TransitionType.show: + return TransitionType.dismiss; + case TransitionType.dismiss: + return TransitionType.none; + case TransitionType.push: + return TransitionType.pop; + case TransitionType.pop: + return TransitionType.none; + case TransitionType.none: + return TransitionType.none; + } } type LocationDescriptor = RoutePath | GeneratedRoutePath | LocationDescriptorObject; @@ -60,7 +38,7 @@ type LocationDescriptor = RoutePath | GeneratedRoutePath | LocationDescriptorObj type LocationListener = ( location: Location, action: Action, - transition: ITransitionSpecification, + transition: TransitionType, ) => void; export default class History { @@ -95,7 +73,7 @@ export default class History { } public push = (nextLocation: LocationDescriptor, nextState?: Partial) => { - const state = { transition: transitions.push, ...nextState }; + const state = { transition: TransitionType.push, ...nextState }; this.pushImpl(nextLocation, state); this.notify(state.transition); }; @@ -113,7 +91,7 @@ export default class History { this.index = 0; this.entries = [location]; - this.notify(nextState?.transition ?? transitions.none); + this.notify(nextState?.transition ?? TransitionType.none); }; public replaceRoot = ( @@ -125,7 +103,7 @@ export default class History { this.entries.splice(0, 1, location); if (this.index === 0) { - this.notify(replacementState?.transition ?? transitions.none); + this.notify(replacementState?.transition ?? TransitionType.none); } }; @@ -188,7 +166,7 @@ export default class History { this.entries.splice(this.index, this.entries.length - this.index, location); } - private popImpl(n = 1): ITransitionSpecification | undefined { + private popImpl(n = 1): TransitionType | undefined { if (this.canGo(-n)) { const transition = this.getPopTransition(n); @@ -202,7 +180,7 @@ export default class History { } } - private notify(transition: ITransitionSpecification) { + private notify(transition: TransitionType) { this.listeners.forEach((listener) => listener(this.location, this.action, transition)); } @@ -242,7 +220,7 @@ export default class History { return { scrollPosition: state?.scrollPosition ?? [0, 0], expandedSections: state?.expandedSections ?? {}, - transition: state?.transition ?? transitions.none, + transition: state?.transition ?? TransitionType.none, }; } diff --git a/gui/src/shared/ipc-types.ts b/gui/src/shared/ipc-types.ts index c186c9ac8392..67d1539b36a7 100644 --- a/gui/src/shared/ipc-types.ts +++ b/gui/src/shared/ipc-types.ts @@ -1,6 +1,6 @@ import { Action, Location } from 'history'; -import { ITransitionSpecification } from '../renderer/lib/history'; +import { TransitionType } from '../renderer/lib/history'; export interface ICurrentAppVersionInfo { gui: string; @@ -18,7 +18,7 @@ export type IChangelog = Array; export interface LocationState { scrollPosition: [number, number]; expandedSections: Record; - transition: ITransitionSpecification; + transition: TransitionType; } export interface IHistoryObject {