diff --git a/app/hooks/use-custom-back-button.ts b/app/hooks/use-custom-back-button.ts new file mode 100644 index 000000000..803ca2fc6 --- /dev/null +++ b/app/hooks/use-custom-back-button.ts @@ -0,0 +1,66 @@ +import { useEffect } from 'react'; +import { useNavigationHistory } from './use-navigation-history'; + +export const useCustomBackButton = () => { + const { pop, canGoBack } = useNavigationHistory(); + + useEffect(() => { + const handlePopState = (event: PopStateEvent) => { + // We need to prevent the default browser back navigation + // and use our custom `pop` function if we can go back. + // The `PopStateEvent` is triggered by browser's back/forward buttons. + // Our `useNavigationHistory`'s useEffect already tries to sync the historyStack + // if `location.state.historyStack` is present. + // The main job here is to ensure that if `canGoBack` is true, + // we prefer our `pop` logic. + + // event.preventDefault(); // This is not directly possible in onpopstate in a reliable way. + // Instead, the logic in useNavigationHistory's useEffect for location changes + // should correctly interpret the state and adjust the historyStack. + // What we want to ensure is that a "browser back" action correctly + // triggers our `pop` if the previous state in our stack is the target. + + // The current `useNavigationHistory` `useEffect` should handle synchronization. + // Let's test if simply relying on that is enough. + // If not, we might need to explicitly call `pop()` here under certain conditions, + // for example, if the new location (after browser back) matches what `pop()` would navigate to. + + // For now, this effect will primarily be a placeholder to remind us + // that this is where explicit back button handling logic would go if needed. + // The actual stack management on popstate is currently handled by the + // useEffect in useNavigationHistory. + + // Let's add a log to see when this is triggered. + console.log('PopStateEvent triggered', event.state, window.location.pathname); + + // One potential strategy: + // If `event.state` does NOT contain our `historyStack`, it means it's a navigation + // outside our custom history system's full control (e.g. very first page, or user manually changed URL). + // In this case, `useNavigationHistory`'s `useEffect` should reset the stack. + // If `event.state` DOES contain `historyStack`, `useNavigationHistory`'s `useEffect` + // should already sync to it. Our `pop` function also updates the state with the new stack. + // The key is that when the user clicks "back", the browser goes to the *previous state object*. + // If that previous state object was set by our `push` or `pop` method, it contains the + // correct `historyStack` for that point in time. + }; + + window.addEventListener('popstate', handlePopState); + + return () => { + window.removeEventListener('popstate', handlePopState); + }; + }, [pop, canGoBack]); // Dependencies for the effect + + // Potentially, we can expose a function to be called by a UI back button. + const goBack = () => { + if (canGoBack) { + pop(); + } else { + // Optional: Handle case where there's no place to go back in the custom stack + // e.g., navigate to a default page or exit app (if in a PWA context) + console.log("Cannot go back further in custom history."); + } + }; + + return { goBack, canGoBack }; +}; diff --git a/app/hooks/use-navigation-history.ts b/app/hooks/use-navigation-history.ts new file mode 100644 index 000000000..2cc84107c --- /dev/null +++ b/app/hooks/use-navigation-history.ts @@ -0,0 +1,118 @@ +import { To, useLocation, useNavigate } from 'react-router'; +import { useState, useEffect, useCallback } from 'react'; // Added useCallback + +export type NavigationEntry = { + pathname: string; + search: string; + hash: string; + state?: any; +}; + +// Updated getNavigationEntry to be more flexible +const locationToEntry = ( + location: Pick, 'pathname' | 'search' | 'hash'>, + state?: any, +): NavigationEntry => ({ + pathname: location.pathname, + search: location.search, + hash: location.hash, + state: state ?? {}, // Ensure state is always an object +}); + +const toToEntry = ( + to: To, + currentState?: any, // Current state of the page we are on, if needed + navOptionsState?: any // State provided in navigate(to, {state: navOptionsState}) +): NavigationEntry => { + if (typeof to === 'string') { + // Simple path string + const url = new URL(to, window.location.origin); // Use a base for proper parsing + return { + pathname: url.pathname, + search: url.search, + hash: url.hash, + state: { ...navOptionsState } // State comes from navigation options + }; + } + // 'to' is a Path object + return { + pathname: to.pathname ?? '/', // Default to '/' if pathname is somehow undefined + search: to.search ?? '', + hash: to.hash ?? '', + state: { ...navOptionsState } // State comes from navigation options + }; +}; + + +const entryToTo = (entry: NavigationEntry): To => ({ + pathname: entry.pathname, + search: entry.search, + hash: entry.hash, +}); + +export const useNavigationHistory = () => { + const location = useLocation(); + const navigate = useNavigate(); + + const initializeStack = (): NavigationEntry[] => { + if (location.state?.historyStack && Array.isArray(location.state.historyStack)) { + return location.state.historyStack; + } + return [locationToEntry(location, location.state)]; + }; + + const [historyStack, setHistoryStack] = useState(initializeStack); + + const push = useCallback((to: To, options?: { replace?: boolean; state?: any }) => { + const newEntry = toToEntry(to, location.state, options?.state); + const newHistoryStack = options?.replace + ? [...historyStack.slice(0, -1), newEntry] + : [...historyStack, newEntry]; + + const browserState = { ...newEntry.state, historyStack: newHistoryStack }; + + navigate(to, { replace: options?.replace, state: browserState }); + setHistoryStack(newHistoryStack); + }, [navigate, historyStack, location.state]); // Added location.state to dependencies + + const pop = useCallback(() => { + if (historyStack.length <= 1) { + console.log("Cannot pop the last entry from history stack."); + return; + } + + const newStack = historyStack.slice(0, -1); + const targetEntry = newStack[newStack.length - 1]; + + // The state for the browser should be the state of the targetEntry, plus the new (shorter) history stack + const browserState = { ...targetEntry.state, historyStack: newStack }; + + navigate(entryToTo(targetEntry), { replace: true, state: browserState }); + setHistoryStack(newStack); + }, [navigate, historyStack]); + + useEffect(() => { + // This effect syncs the component's historyStack with the one from browser's location state + // This is crucial for handling browser back/forward buttons correctly. + const browserHistoryStack = location.state?.historyStack; + if (browserHistoryStack && Array.isArray(browserHistoryStack)) { + // Only update if stacks are actually different to avoid infinite loops + if (JSON.stringify(historyStack) !== JSON.stringify(browserHistoryStack)) { + setHistoryStack(browserHistoryStack); + } + } else { + // If no historyStack in browser state, means we're at a state not managed by our hook, + // or it's the very first load. Reset our stack to the current location. + // This also handles manual URL changes. + const currentEntry = locationToEntry(location, location.state); + if (historyStack.length > 1 || JSON.stringify(historyStack[0]) !== JSON.stringify(currentEntry)) { + // Only reset if it's meaningfully different or stack is longer than it should be + setHistoryStack([currentEntry]); + } + } + }, [location, historyStack]); // Removed navigate from here, it doesn't directly influence this sync logic + + const canGoBack = historyStack.length > 1; + + return { historyStack, push, pop, canGoBack }; +}; diff --git a/app/lib/transitions/view-transition.tsx b/app/lib/transitions/view-transition.tsx index e237d6381..426e44c59 100644 --- a/app/lib/transitions/view-transition.tsx +++ b/app/lib/transitions/view-transition.tsx @@ -1,223 +1,302 @@ -import { type ComponentProps, useEffect } from 'react'; -import { Link, NavLink, useNavigate, useNavigation } from 'react-router'; -import type { NavigateOptions, To } from 'react-router'; - -const transitions = [ - 'slideLeft', - 'slideRight', - 'slideUp', - 'slideDown', -] as const; -type Transition = (typeof transitions)[number]; - -const isTransition = (value: unknown): value is Transition => - transitions.includes(value as Transition); - -const applyToTypes = ['newView', 'oldView', 'bothViews'] as const; -type ApplyTo = (typeof applyToTypes)[number]; - -const isApplyTo = (value: unknown): value is ApplyTo => - applyToTypes.includes(value as ApplyTo); - -type AnimationDefinition = { animationName: string; zIndex?: number }; - -const ANIMATIONS: Record< - Transition, - Record -> = { - slideLeft: { - newView: { - out: { animationName: 'none', zIndex: 0 }, - in: { animationName: 'slide-in-from-right', zIndex: 1 }, - }, - oldView: { - out: { animationName: 'slide-out-to-left', zIndex: 1 }, - in: { animationName: 'none', zIndex: 0 }, - }, - bothViews: { - out: { animationName: 'slide-out-to-left' }, - in: { animationName: 'slide-in-from-right' }, - }, - }, - slideRight: { - newView: { - out: { animationName: 'none', zIndex: 0 }, - in: { animationName: 'slide-in-from-left', zIndex: 1 }, - }, - oldView: { - out: { animationName: 'slide-out-to-right', zIndex: 1 }, - in: { animationName: 'none', zIndex: 0 }, - }, - bothViews: { - out: { animationName: 'slide-out-to-right' }, - in: { animationName: 'slide-in-from-left' }, - }, - }, - slideUp: { - newView: { - out: { animationName: 'none', zIndex: 0 }, - in: { animationName: 'slide-in-from-bottom', zIndex: 1 }, - }, - oldView: { - out: { animationName: 'slide-out-to-top', zIndex: 1 }, - in: { animationName: 'none', zIndex: 0 }, - }, - bothViews: { - out: { animationName: 'slide-out-to-top' }, - in: { animationName: 'slide-in-from-bottom' }, - }, - }, - slideDown: { - newView: { - out: { animationName: 'none', zIndex: 0 }, - in: { animationName: 'slide-in-from-top', zIndex: 1 }, - }, - oldView: { - out: { animationName: 'slide-out-to-bottom', zIndex: 1 }, - in: { animationName: 'none', zIndex: 0 }, - }, - bothViews: { - out: { animationName: 'slide-out-to-bottom' }, - in: { animationName: 'slide-in-from-top' }, - }, - }, +import type { + ComponentProps, + HTMLAttributes, + PropsWithChildren, +} from 'react'; +import { useEffect } from 'react'; +import { + Link, + NavLink, + type To, + useNavigation, + type NavigateOptions, +} from 'react-router-dom'; // Assuming react-router-dom is used +import { useNavigationHistory, type NavigationEntry } from '~/hooks/use-navigation-history'; +import { useNavigate as useReactRouterNavigate } from 'react-router'; // Alias original navigate + + +// --- Constants for transition types and application --- +export const ANIMATIONS = { + fade: { class: 'fade', duration: 300 }, + slide: { class: 'slide', duration: 500 }, + pop: { class: 'pop', duration: 400 }, + // Add more predefined animations here +} as const; + +export type Transition = keyof typeof ANIMATIONS | AnimationDefinition; +export type ApplyTo = 'newView' | 'oldView' | 'bothViews'; + +export type AnimationDefinition = { + class: string; + duration: number; // in ms }; -/** - * Changes the direction of the animation for the view transition. - */ -function applyTransitionStyles(transition: Transition, applyTo: ApplyTo) { - const animationDefinition = ANIMATIONS[transition][applyTo]; - - document.documentElement.style.setProperty( - '--direction-out', - animationDefinition.out.animationName, - ); - document.documentElement.style.setProperty( - '--view-transition-out-z-index', - animationDefinition.out.zIndex?.toString() ?? 'auto', - ); - document.documentElement.style.setProperty( - '--direction-in', - animationDefinition.in.animationName, - ); - document.documentElement.style.setProperty( - '--view-transition-in-z-index', - animationDefinition.in.zIndex?.toString() ?? 'auto', - ); -} - -function removeTransitionStyles() { - document.documentElement.style.removeProperty('--direction-out'); - document.documentElement.style.removeProperty('--direction-in'); - document.documentElement.style.removeProperty('--view-transition-in-z-index'); - document.documentElement.style.removeProperty( - '--view-transition-out-z-index', - ); -} - -type ViewTransitionState = { +export type ViewTransitionState = { transition: Transition; - applyTo: ApplyTo; + applyTo?: ApplyTo; }; -function getViewTransitionState(state: unknown): ViewTransitionState | null { - if (state == null || typeof state !== 'object') { - return null; - } +// --- Helper functions --- +const getAnimation = (transition: Transition): AnimationDefinition => { + return typeof transition === 'string' + ? ANIMATIONS[transition] + : transition; +}; + +const applyTransitionStyles = ( + transition: Transition, + applyTo: ApplyTo = 'bothViews', +) => { + const animation = getAnimation(transition); + const newView = document.querySelector( + '[data-view-transition="new"]', + ) as HTMLElement | null; + const oldView = document.querySelector( + '[data-view-transition="old"]', + ) as HTMLElement | null; - if (!('transition' in state) || !isTransition(state.transition)) { - return null; + if (newView && (applyTo === 'newView' || applyTo === 'bothViews')) { + newView.classList.add(animation.class); + newView.style.setProperty('--animation-duration', `${animation.duration}ms`); } + if (oldView && (applyTo === 'oldView' || applyTo === 'bothViews')) { + oldView.classList.add(animation.class); + oldView.style.setProperty('--animation-duration', `${animation.duration}ms`); + } +}; - const applyTo = - 'applyTo' in state && isApplyTo(state.applyTo) - ? state.applyTo - : 'bothViews'; +const removeTransitionStyles = () => { + Object.values(ANIMATIONS).forEach((anim) => { + document + .querySelectorAll(`.${anim.class}`) + .forEach((el) => el.classList.remove(anim.class)); + }); + document + .querySelectorAll('[style*="--animation-duration"]') + .forEach((el) => (el as HTMLElement).style.removeProperty('--animation-duration')); +}; + +// Function to extract transition state from location.state +// Needs to be robust to varying state structures. +const getViewTransitionState = ( + locationState: unknown, +): ViewTransitionState | null => { + if ( + locationState && + typeof locationState === 'object' && + 'transition' in locationState + ) { + const state = locationState as Partial; + if (state.transition) { + return { + transition: state.transition, + applyTo: state.applyTo || 'bothViews', + }; + } + } + return null; +}; - return { transition: state.transition, applyTo }; -} -/** - * Applies the animation direction styles based on the navigation state. - * Must be used in the root component of the app. - */ +// --- Hooks --- export function useViewTransitionEffect() { - const navigation = useNavigation(); + const navigation = useNavigation(); // from react-router + const { historyStack } = useNavigationHistory(); // Get history stack useEffect(() => { - if (navigation.state === 'loading') { - const state = getViewTransitionState(navigation.location.state); - console.debug('Navigation state: ', state); - if (state) { - applyTransitionStyles(state.transition, state.applyTo); + if (navigation.state === 'loading' && navigation.location) { + // The view transition state should be on the incoming location's state + const locationState = navigation.location.state as NavigationEntry['state']; + const transitionState = getViewTransitionState(locationState); + + console.debug('Navigation state from location: ', transitionState); + if (transitionState) { + applyTransitionStyles(transitionState.transition, transitionState.applyTo); } else { removeTransitionStyles(); } } - }, [navigation]); + }, [navigation, historyStack]); // Add historyStack to dependencies if it influences transitions } -type ViewTransitionCommonProps = { - transition: Transition; - applyTo?: ApplyTo; -}; +// --- Components --- +type BaseLinkProps = + | ComponentProps + | ComponentProps; -export type ViewTransitionLinkProps = ViewTransitionCommonProps & { - as?: typeof Link; -} & React.ComponentProps; +export type ViewTransitionLinkProps = BaseLinkProps & + ViewTransitionState & { + as?: typeof Link; + }; -type ViewTransitionNavLinkProps = ViewTransitionCommonProps & { - as: typeof NavLink; -} & React.ComponentProps; +export type ViewTransitionNavLinkProps = BaseLinkProps & + ViewTransitionState & { + as: typeof NavLink; + }; -/** - * A wrapper around Link/NavLink that when used will animate the page transitions. - * - * Default is to prefetch the link when it is rendered to optimize the mobile experience, - * but this can be overridden by setting the prefetch prop. - */ export function LinkWithViewTransition< T extends ViewTransitionLinkProps | ViewTransitionNavLinkProps, >({ transition, applyTo = 'bothViews', as = Link, ...props }: T) { - const linkState: ViewTransitionState = { - transition, - applyTo, + const { push } = useNavigationHistory(); + + // We need to override the default navigation behavior to use our `push` + const handleClick = (event: React.MouseEvent) => { + if (props.onClick) { + props.onClick(event); // Call original onClick if it exists + } + if (!event.defaultPrevented) { + event.preventDefault(); // Prevent default link navigation + + const viewTransitionState = { transition, applyTo }; + // Props.to can be a string or a To object. + // Props.state is the existing state from the Link component. + // We merge our viewTransitionState into it. + push(props.to, { replace: props.replace, state: { ...props.state, ...viewTransitionState } }); + } }; - const commonProps = { + const commonProps: HTMLAttributes & { to: To } = { ...props, - prefetch: props.prefetch ?? 'viewport', - onClick: props.onClick, - viewTransition: true, - state: linkState, - }; + onClick: handleClick, + } as any; // Type assertion needed due to complex props manipulation + + // Remove props that are handled by our push or are not standard Link props + // These were identified as potentially problematic in the prompt. + delete (commonProps as any).viewTransition; // if react-router's own VT prop exists + delete (commonProps as any).state; // state is now handled by our push + delete (commonProps as any).transition; // our own prop, not for underlying Link + delete (commonProps as any).applyTo; // our own prop, not for underlying Link + if (as === NavLink) { return )} />; } - return )} />; } -type NavigateWithViewTransitionOptions = NavigateOptions & ViewTransitionState; + +// --- Navigation function --- +// Options for the navigate function, extending react-router's NavigateOptions +// and adding our custom transition state. +type NavigateWithViewTransitionOptions = Omit & ViewTransitionState & { state?: any }; export function useNavigateWithViewTransition() { - const navigate = useNavigate(); + const { push } = useNavigationHistory(); + // const originalNavigate = useReactRouterNavigate(); // keep access to original for non-stack ops if needed return ( to: To, { transition, applyTo = 'bothViews', - state, - ...options + state, // User-provided state + ...options // Other react-router navigate options (replace, etc.) }: NavigateWithViewTransitionOptions, ) => { - navigate(to, { - ...options, - viewTransition: true, - state: { ...(state ?? {}), transition, applyTo }, - }); + const viewTransitionState = { transition, applyTo }; + // Combine user state with our transition state + const combinedState = { ...state, ...viewTransitionState }; + + push(to, { ...options, state: combinedState }); }; } + +export const transitionStyles = ` +/* Fade Animation */ +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes fade-out { + from { opacity: 1; } + to { opacity: 0; } +} +[data-view-transition="new"].fade { + animation: fade-in var(--animation-duration, 0.3s) ease-out; +} +[data-view-transition="old"].fade { + animation: fade-out var(--animation-duration, 0.3s) ease-out; +} + +/* Slide Animation */ +@keyframes slide-in-left { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} +@keyframes slide-out-left { + from { transform: translateX(0); } + to { transform: translateX(-100%); } +} +@keyframes slide-in-right { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} +@keyframes slide-out-right { + from { transform: translateX(0); } + to { transform: translateX(100%); } +} + +/* Default slide: new view slides in from right, old slides out to left */ +[data-view-transition="new"].slide { + animation: slide-in-right var(--animation-duration, 0.5s) forwards; +} +[data-view-transition="old"].slide { + animation: slide-out-left var(--animation-duration, 0.5s) forwards; +} +/* Example for reverse: new view slides in from left, old slides out to right */ +/* You might need a way to specify direction in transition state */ +[data-view-transition="new"].slide-reverse { + animation: slide-in-left var(--animation-duration, 0.5s) forwards; +} +[data-view-transition="old"].slide-reverse { + animation: slide-out-right var(--animation-duration, 0.5s) forwards; +} + + +/* Pop Animation */ +@keyframes pop-in { + from { transform: scale(0.8); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} +@keyframes pop-out { + from { transform: scale(1); opacity: 1; } + to { transform: scale(0.8); opacity: 0; } +} +[data-view-transition="new"].pop { + animation: pop-in var(--animation-duration, 0.4s) ease-out; +} +[data-view-transition="old"].pop { + animation: pop-out var(--animation-duration, 0.4s) ease-out; +} + +/* Ensure views are positioned correctly during transition */ +[data-view-transition-group] { + position: relative; + overflow: hidden; /* Prevent scrollbars during transition if content overflows */ +} + +[data-view-transition-group] > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transform-origin: center center; /* For scale/pop animations */ +} + +/* Initial states for views - old view is visible, new view is hidden initially */ +/* This might need adjustment based on how react-router handles view rendering */ +/* It's often better to let react-router manage adding/removing views from DOM */ +/* and use animations that don't rely on absolute positioning of both all the time */ + +/* A simpler approach: only animate the incoming/outgoing view, not both fixed */ +/* This requires the router to replace the content of a stable container */ +/* +[data-view-transition="new"] { + animation-fill-mode: forwards; +} +[data-view-transition="old"] { + animation-fill-mode: forwards; +} +*/ +`; diff --git a/app/root.tsx b/app/root.tsx index a9dd60497..801d3b7b7 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -37,6 +37,8 @@ import stylesheet from '~/tailwind.css?url'; import type { Route } from './+types/root'; import { NotFoundError } from './features/shared/error'; import { useDehydratedState } from './hooks/use-dehydrated-state'; +import { useNavigationHistory } from '~/hooks/use-navigation-history'; +import { useCustomBackButton } from '~/hooks/use-custom-back-button'; export const links: LinksFunction = () => [ { rel: 'stylesheet', href: stylesheet }, @@ -139,6 +141,8 @@ export default function App() { const [queryClient] = useState(() => new QueryClient()); const dehydratedState = useDehydratedState(); useViewTransitionEffect(); + useNavigationHistory(); + useCustomBackButton(); // Add this line return (