39948-vm/frontend/src/lib/navigationHelpers.ts

235 lines
6.7 KiB
TypeScript

/**
* Navigation Helpers
*
* Shared utilities for page navigation in RuntimePresentation and constructor.tsx.
* Handles target page resolution, back navigation detection, and transition blocking.
*/
import type { RuntimePage } from '../types/runtime';
import type {
NavigableElement,
NavigationTarget,
TransitionPhase,
} from '../types/presentation';
import { parseJsonObject } from './parseJson';
/**
* Context for resolving history-based back navigation
*/
export interface NavigationContext {
currentPageSlug?: string;
previousPageId?: string | null;
}
/**
* UI schema structure for type-safe parsing
*/
interface UiSchemaStructure {
elements?: Array<Record<string, unknown>>;
}
/**
* Find navigation element on sourcePage that points to targetPageSlug.
* Used for history-based back navigation to get the forward transition.
*
* @param sourcePage - The page to search for navigation elements
* @param targetPageSlug - The target page slug to find
* @returns The navigation element pointing to target page, or null if not found
*/
export const findIncomingNavigationElement = (
sourcePage: {
ui_schema_json?: string | Record<string, unknown>;
slug?: string;
},
targetPageSlug: string,
): NavigableElement | null => {
// Parse ui_schema_json using shared utility
const uiSchema = parseJsonObject<UiSchemaStructure>(
sourcePage.ui_schema_json,
{},
);
const elements = Array.isArray(uiSchema.elements) ? uiSchema.elements : [];
// Find navigation element pointing to target page
const found = elements.find(
(el) =>
isNavigationType(String(el.type || '')) &&
el.targetPageSlug === targetPageSlug,
);
if (!found) return null;
// Type assertion after validation
return found as unknown as NavigableElement;
};
/**
* Resolve history-based back navigation target.
* Finds the previous page from history and looks up the forward transition that was used to arrive.
*
* @param pages - Available pages
* @param currentPageSlug - Current page slug
* @param previousPageId - Previous page ID from history
* @returns Navigation target or null if previous page not found
*/
export const resolveHistoryBackTarget = (
pages: RuntimePage[],
currentPageSlug: string,
previousPageId: string | null,
): NavigationTarget | null => {
if (!previousPageId) return null;
const previousPage = pages.find((p) => p.id === previousPageId);
if (!previousPage) return null;
// Look up the forward navigation element that brought user to current page
const incomingElement = findIncomingNavigationElement(
previousPage,
currentPageSlug,
);
return {
page: previousPage,
pageId: previousPage.id,
transitionVideoUrl: incomingElement?.transitionVideoUrl,
transitionReverseMode: incomingElement?.transitionReverseMode,
reverseVideoUrl: incomingElement?.reverseVideoUrl,
isBack: true,
};
};
/**
* Resolve target page from element navigation properties.
* Supports both targetPageSlug (new) and targetPageId (legacy).
* Also supports history-based back navigation when navBackMode='history'.
*
* @param element - Element with navigation properties
* @param pages - Available pages to search
* @param context - Optional context for history-based navigation
* @returns The target page or undefined if not found
*/
export const resolveNavigationTarget = (
element: NavigableElement,
pages: RuntimePage[],
context?: NavigationContext,
): NavigationTarget | null => {
// Handle history-based back navigation
if (isBackNavigation(element) && element.navBackMode === 'history') {
return resolveHistoryBackTarget(
pages,
context?.currentPageSlug || '',
context?.previousPageId || null,
);
}
// Standard target_page mode logic
const targetPageSlug = element.targetPageSlug;
const legacyTargetPageId = element.targetPageId;
let targetPage: RuntimePage | undefined;
if (targetPageSlug) {
targetPage = pages.find((p) => p.slug === targetPageSlug);
} else if (legacyTargetPageId) {
targetPage = pages.find((p) => p.id === legacyTargetPageId);
}
if (!targetPage) {
return null;
}
const isBack = isBackNavigation(element);
return {
page: targetPage,
pageId: targetPage.id,
transitionVideoUrl: element.transitionVideoUrl,
transitionReverseMode: element.transitionReverseMode,
reverseVideoUrl: element.reverseVideoUrl,
isBack,
};
};
/**
* Determine if navigation direction is "back".
* Elements with navType='back' or type='navigation_prev' navigate backwards.
*
* @param element - Element to check
* @returns true if this is a back navigation
*/
export const isBackNavigation = (element: NavigableElement): boolean => {
return element.navType === 'back' || element.type === 'navigation_prev';
};
/**
* Get navigation direction based on element properties.
*
* @param element - Element with navigation properties
* @returns 'back' or 'forward'
*/
export const getNavigationDirection = (
element: NavigableElement,
): 'back' | 'forward' => {
return isBackNavigation(element) ? 'back' : 'forward';
};
/**
* Check if transition is actively blocking navigation.
* Navigation should be blocked during preparing or playing phases.
*
* @param transitionPhase - Current transition phase
* @param isBuffering - Whether video is buffering
* @returns true if navigation should be blocked
*/
export const isTransitionBlocking = (
transitionPhase: TransitionPhase,
isBuffering: boolean,
): boolean => {
const activePhases: TransitionPhase[] = ['preparing', 'playing'];
return activePhases.includes(transitionPhase) || isBuffering;
};
/**
* Check if element has a playable transition.
* A transition is playable if:
* - Forward navigation: has a video URL
* - Back navigation: has a video URL AND a reversed video URL
* (reversed video is pre-generated by backend)
*
* @param element - Element with transition properties
* @param direction - Navigation direction
* @returns true if element has a playable transition
*/
export const hasPlayableTransition = (
element: {
transitionVideoUrl?: string;
reverseVideoUrl?: string;
},
direction: 'back' | 'forward' = 'forward',
): boolean => {
if (!element.transitionVideoUrl) {
return false;
}
// For back navigation, require pre-reversed video (generated by backend)
if (direction === 'back' && !element.reverseVideoUrl) {
return false;
}
return true;
};
/**
* Check if element is a navigation element type.
*
* @param elementType - Element type to check
* @returns true if element is a navigation type
*/
export const isNavigationType = (elementType: string): boolean => {
return (
elementType === 'navigation_next' ||
elementType === 'navigation_prev' ||
elementType === 'navigation'
);
};