22 KiB
Page Navigation
Documentation for the Tour Builder Platform's page navigation system using element-based navigation stored in ui_schema_json.
Overview
The platform uses a simplified navigation system where navigation configuration is stored directly in tour_pages.ui_schema_json as part of element definitions. This approach:
- Eliminates ID remapping issues when publishing between environments
- Uses page slugs instead of UUIDs for cross-environment consistency
- Stores transition video URLs directly on navigation elements
┌─────────────────────────────────────────────────────────────────────────────┐
│ Page Navigation Architecture │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Navigation Source │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ tour_pages.ui_schema_json │ │ │
│ │ │ │ │ │
│ │ │ elements: [{ │ │ │
│ │ │ type: "navigation_next", │ │ │
│ │ │ targetPageSlug: "page-2", │ │ │
│ │ │ transitionVideoUrl: "assets/.../video.mp4", │ │ │
│ │ │ ... │ │ │
│ │ │ }] │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │ Navigation Resolution │ │ │
│ │ │ │ │ │
│ │ │ • Resolve slug to page │ │ │
│ │ │ • Determine direction │ │ │
│ │ │ • Get preloaded blob URL │ │ │
│ │ └───────────────┬───────────────┘ │ │
│ │ │ │ │
│ │ ┌───────────────┴───────────────┐ │ │
│ │ ▼ ▼ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ With Transition │ │ Direct Navigate │ │ │
│ │ │ │ │ │ │ │
│ │ │ Play video → │ │ Switch page → │ │ │
│ │ │ Switch page │ │ Update history │ │ │
│ │ └──────────────────┘ └──────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Data Model
Navigation in ui_schema_json
Navigation is configured directly within element objects in the tour_pages.ui_schema_json field:
// Element with navigation configuration
{
id: "element-uuid",
type: "navigation_next", // or "navigation_prev"
label: "Next Page Button",
// Position
xPercent: 90,
yPercent: 85,
// Navigation Configuration
navigationTargetMode: "target_page", // "target_page" or "external_url"
targetPageSlug: "page-2", // Slug-based navigation (consistent across environments)
externalUrl: undefined, // Used when navigationTargetMode is "external_url"
transitionVideoUrl: "assets/transitions/fade.mp4",
transitionDurationSec: 0.7,
transitionReverseMode: "auto_reverse", // or "separate_video"
reverseVideoUrl: undefined, // Only used with "separate_video" mode
// Element Styling
iconUrl: "assets/icons/arrow-right.png",
// ... extends ElementStyleProperties
}
Key Fields
| Field | Type | Description |
|---|---|---|
type |
string | navigation_next or navigation_prev determines direction |
navigationTargetMode |
'target_page' | 'external_url' |
Destination mode for forward navigation buttons. Defaults to target_page |
targetPageSlug |
string | Target page slug (NOT UUID) for cross-environment consistency |
externalUrl |
string | External URL opened in a new tab when navigationTargetMode is external_url |
transitionVideoUrl |
string | URL to transition video (optional) |
transitionDurationSec |
number | Duration in seconds (optional) |
transitionReverseMode |
'auto_reverse' | 'separate_video' |
Reverse playback mode (replaces deprecated supportsReverse) |
reverseVideoUrl |
string | Separate video URL for reverse playback (when mode is separate_video) |
Why Slugs Instead of UUIDs
Previous versions used targetPageId (UUID) which caused issues:
- When pages are copied between environments (dev → stage → production), UUIDs change
- References to old UUIDs became invalid after publishing
The slug-based approach solves this:
- Slugs are unique within project+environment
- Slugs remain identical across environments (same page has same slug in dev/stage/prod)
- No ID remapping needed during publish
Navigation Types
Forward Navigation (navigation_next)
Navigates to a specified target page with optional transition.
{
type: "navigation_next",
navigationTargetMode: "target_page",
targetPageSlug: "gallery",
transitionVideoUrl: "assets/transitions/zoom-in.mp4",
transitionReverseMode: "auto_reverse"
}
Behavior:
- Target page resolved by slug
- Transition video plays forward if specified
- Page added to navigation history
External URL Navigation (navigation_next)
Forward navigation buttons can open an external URL instead of targeting a tour page:
{
type: "navigation_next",
navType: "forward",
navigationTargetMode: "external_url",
externalUrl: "https://example.com"
}
Behavior:
- The button is treated as forward navigation
- Target page selection is disabled and
targetPageSlug/targetPageIdare cleared - The URL opens in a new tab with
noopener,noreferrer - If the URL omits
http://orhttps://, the runtime opens it with anhttps://prefix - External URL buttons are not included in internal page-link extraction, neighbor preloading, or reversed transition video generation
Back Navigation (navigation_prev)
Returns to previous page with optional reverse transition.
{
type: "navigation_prev",
targetPageSlug: "home", // Optional - can use history
transitionVideoUrl: "assets/transitions/zoom-in.mp4",
transitionReverseMode: "auto_reverse" // Plays in reverse for back nav
}
Alternative with separate reverse video:
{
type: "navigation_prev",
targetPageSlug: "home",
transitionVideoUrl: "assets/transitions/zoom-in.mp4",
transitionReverseMode: "separate_video",
reverseVideoUrl: "assets/transitions/zoom-out.mp4"
}
Behavior:
- Target page optional (uses page history)
- If
transitionReverseMode = 'auto_reverse', video plays in reverse - If
transitionReverseMode = 'separate_video', usesreverseVideoUrlinstead - Page popped from navigation history
Navigation Flow
Runtime Resolution
Files:
frontend/src/components/RuntimePresentation.tsxfrontend/src/lib/navigationHelpers.ts
Navigation uses resolveNavigationTarget helper and usePageSwitch hook:
import { resolveNavigationTarget, isTransitionBlocking } from '../lib/navigationHelpers';
// Extract navigation links from pages
const { pageLinks, preloadElements } = extractPageLinksAndElements(pages);
// Initialize preload orchestrator
const preloadOrchestrator = usePreloadOrchestrator({
pages,
pageLinks,
elements: preloadElements,
currentPageId: selectedPageId,
enabled: !isLoading,
});
// Initialize page switch with preload cache
const pageSwitch = usePageSwitch({
preloadCache: {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, // O(1) instant lookup
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
},
});
// Handle navigation element click
const handleElementClick = useCallback((element) => {
// Block navigation during active transitions
if (isTransitionBlocking(transitionPhase, isBuffering)) {
return;
}
// Resolve target page (supports both slug and legacy ID)
const navTarget = resolveNavigationTarget(element, pages);
if (!navTarget) return;
// Navigate with transition if configured
if (element.transitionVideoUrl) {
// Start transition playback, then switch page on completion
startTransition({
videoUrl: element.transitionVideoUrl,
storageKey: element.transitionVideoUrl, // Raw path for cache lookup
reverseMode: navTarget.isBack ? getReverseMode(element) : 'none',
reverseVideoUrl: element.reverseVideoUrl,
targetPageId: navTarget.pageId,
});
} else {
// Direct navigation (no transition)
pageSwitch.switchToPage(navTarget.page, () => {
setSelectedPageId(navTarget.pageId);
});
}
}, [pages, pageSwitch, transitionPhase, isBuffering]);
Navigation Target Resolution (navigationHelpers.ts):
// Supports both targetPageSlug (preferred) and targetPageId (legacy)
export const resolveNavigationTarget = (element, pages) => {
let targetPage;
if (element.targetPageSlug) {
targetPage = pages.find(p => p.slug === element.targetPageSlug);
} else if (element.targetPageId) {
targetPage = pages.find(p => p.id === element.targetPageId);
}
if (!targetPage) return null;
const isBack = element.navType === 'back' || element.type === 'navigation_prev';
return {
page: targetPage,
pageId: targetPage.id,
transitionVideoUrl: element.transitionVideoUrl,
isBack,
};
};
Page History Management
Page history is managed by the shared usePageNavigation hook (used by both RuntimePresentation and constructor):
import { usePageNavigation } from '../hooks/usePageNavigation';
// Hook provides unified history management with browser-like behavior
const {
currentPageId: selectedPageId,
pageHistory,
previousPageId,
applyPageSelection,
getNavigationContext,
} = usePageNavigation({
pages,
defaultPageId: initialPageId,
trackHistory: true,
});
// applyPageSelection handles history automatically:
// - Forward (isBack=false): appends to history, trimmed to MAX_HISTORY_LENGTH=50
// - Back (isBack=true): pops from history if target matches previousPageId
applyPageSelection(targetPageId, isBack);
// getNavigationContext provides context for history-based back navigation
const navContext = getNavigationContext();
// Returns: { currentPageSlug, previousPageId }
const navTarget = resolveNavigationTarget(element, pages, navContext);
History Behavior:
Forward: A → B → C → history: [A, B, C]
Back to B (isBack=true): → history: [A, B] ✓ Pops C
Back to A (isBack=true): → history: [A] ✓ Pops B
Forward to D: → history: [A, D] ✓ Adds D
Transition Playback
With Video Transition
// useTransitionPlayback handles video playback with preload cache integration
const { phase, isBuffering, isReversing, cancel, forceComplete } = useTransitionPlayback({
videoRef,
transition: transitionConfig, // { videoUrl, storageKey, reverseMode, reverseVideoUrl, targetPageId, isBack }
// onComplete receives isBack flag for proper history management
onComplete: (targetPageId, isBack) => {
// Transition finished, switch to target page
pageSwitch.switchToPage(targetPage, () => {
// usePageNavigation hook: pops history on back, appends on forward
applyPageSelection(targetPageId, isBack ?? false);
});
},
preload: {
preloadedUrls: preloadOrchestrator.preloadedUrls,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
},
});
Storage Key Mapping:
The storageKey (raw storage path like assets/project/transition.mp4) is preserved for cache lookup because presigned URL signatures change on each resolution. The lookup priority is:
getReadyBlobUrl(storageKey)→ O(1) instant (same session)getCachedBlobUrl(storageKey)→ Cache API (~5ms, post-refresh)- Fallback to resolved URL lookup
Reverse Playback
When navigating back with transitionReverseMode = 'auto_reverse':
- Video plays from end to beginning using
useReversePlaybackhook - Supports native
playbackRate = -1or frame-stepping fallback
const {
startReverse,
stopReverse,
isReversing,
isBuffering,
canUseNativeReverse,
} = useReversePlayback({
videoRef,
onComplete: finishOverlayTransition,
preloadedUrls,
videoUrl,
getCachedBlobUrl,
});
// For back navigation with auto_reverse mode
if (isBack && transitionReverseMode === 'auto_reverse') {
startReverse();
}
Reverse Mode Options:
| Mode | Behavior |
|---|---|
none |
No transition on back navigation |
auto_reverse |
Same video plays in reverse |
separate_video |
Uses reverseVideoUrl for back navigation |
Constructor Configuration
Setting Up Navigation
File: frontend/src/pages/constructor.tsx
When creating/editing navigation elements:
- Select element type (
navigation_nextornavigation_prev) - Choose target page from dropdown (shows page names/slugs)
- Optionally select transition video from assets
- Configure transition settings (duration, reverse support)
// Saving navigation element
const elementData = {
type: selectedElementType,
targetPageSlug: targetPage?.slug, // Save slug, not ID
transitionVideoUrl: selectedTransitionVideo?.cdn_url,
transitionDurationSec: transitionDuration,
transitionReverseMode: supportsReverse ? 'auto_reverse' : undefined,
reverseVideoUrl: reverseMode === 'separate_video' ? reverseVideo?.cdn_url : undefined,
};
Extracting Navigation Links for Preloading
File: frontend/src/lib/extractPageLinks.ts
The extractPageLinksAndElements utility extracts navigation targets and preloadable elements from pages:
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
// Extract from all pages (filtered by environment)
const filteredPages = pages.filter(p => p.environment === environment);
const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages);
// pageLinks: Array of navigation connections
// [{ sourcePageId, targetPageSlug, transitionVideoUrl, supportsReverse }]
// preloadElements: Array of elements with preloadable assets
// [{ pageId, type, imageUrl, videoUrl, transitionVideoUrl, ... }]
Building Neighbor Graph
File: frontend/src/hooks/useNeighborGraph.ts
The neighbor graph is built from pageLinks for preload prioritization:
const neighborGraph = useNeighborGraph({
pages: filteredPages,
pageLinks, // From extractPageLinksAndElements
currentPageId,
maxDepth: 1, // Only immediate neighbors (reduced from 2)
});
// Returns pages reachable within maxDepth hops
const neighborsToPreload = neighborGraph.getNeighbors(currentPageId);
Preload Priority for Navigation Assets:
| Asset Type | Priority | Notes |
|---|---|---|
| Transition video | +150 | Highest - needed immediately on click |
| Background image | +100 | Required for page display |
| Audio | +50 | Background audio tracks |
| Video | +30 | Can stream, lower priority |
TypeScript Types
File: frontend/src/types/constructor.ts
// Navigation-related fields in CanvasElement
interface CanvasElement extends BaseCanvasElement {
id: string;
type: CanvasElementType; // 'navigation_next' | 'navigation_prev' | ...
label: string;
// Position
xPercent: number;
yPercent: number;
// Navigation (for navigation_next, navigation_prev types)
navType?: NavigationButtonKind; // 'forward' | 'back'
navDisabled?: boolean;
/** @deprecated Use targetPageSlug instead */
targetPageId?: string;
targetPageSlug?: string;
transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string;
transitionDurationSec?: number;
// Styling
iconUrl?: string;
// ... extends ElementStyleProperties for CSS styling
}
// Navigation button direction type
type NavigationButtonKind = 'forward' | 'back';
File: frontend/src/types/presentation.ts
// Navigation target resolved from element
interface NavigationTarget {
page: RuntimePage;
pageId: string;
transitionVideoUrl?: string;
isBack: boolean;
}
// Element with navigation properties (for click handling)
interface NavigableElement {
id: string;
type: string;
targetPageSlug?: string;
targetPageId?: string; // Legacy
transitionVideoUrl?: string;
navType?: 'forward' | 'back';
navDisabled?: boolean;
}
// Transition phase states
type TransitionPhase = 'idle' | 'preparing' | 'playing' | 'reversing' | 'completed';
File References
| File | Purpose |
|---|---|
frontend/src/pages/constructor.tsx |
Navigation element configuration |
frontend/src/components/RuntimePresentation.tsx |
Runtime navigation execution |
frontend/src/lib/extractPageLinks.ts |
Extract navigation links and preload elements from pages |
frontend/src/lib/navigationHelpers.ts |
Navigation target resolution and direction detection |
frontend/src/hooks/usePageSwitch.ts |
Page navigation with preloaded blob URLs |
frontend/src/hooks/usePreloadOrchestrator.ts |
Asset preloading with ready blob URL management |
frontend/src/hooks/usePageNavigation.ts |
History and page state management |
frontend/src/hooks/useNeighborGraph.ts |
Preload graph from navigation |
frontend/src/hooks/useReversePlayback.ts |
Reverse video playback (native or frame-stepping) |
frontend/src/hooks/useTransitionPlayback.ts |
Transition video playback with preloaded URLs |
frontend/src/types/constructor.ts |
Element type definitions |
frontend/src/types/presentation.ts |
Navigation target and element interfaces |
frontend/src/config/preload.config.ts |
Preload priority weights and settings |
extractPageLinks.ts also extracts nested Info Panel target_page destinations from infoPanelSections:
- section-level header/title/text click destinations
- span item click destinations
- image/card/video/360 item click destinations
External URL destinations are not added to the neighbor graph. Info Panel nested media URLs (imageUrl, videoUrl, iconUrl, and header images) are recursively extracted into preload elements.
Environment Filtering
Pages are filtered by environment before navigation resolution:
// Filter pages by current environment (dev, stage, production)
const filteredPages = pages.filter(p => p.environment === environment);
// Extract navigation links only from same-environment pages
const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages);
// Navigation only resolves within the same environment
const targetPage = filteredPages.find(p => p.slug === element.targetPageSlug);
Environment Contexts:
| Context | Environment | Notes |
|---|---|---|
| Constructor | dev |
Editing/preview mode |
| Stage preview | stage |
Pre-production review |
| Public runtime | production |
Published tour playback |
Navigation links pointing to slugs that don't exist in the current environment will fail silently.
Known Considerations
1. Slug Uniqueness
Slugs must be unique within project+environment. The system validates this on page creation/update.
2. Missing Target Pages
If targetPageSlug references a non-existent page in the current environment, navigation silently fails. UI should handle gracefully.
3. Transition Reverse Support
Not all transitions look good in reverse. Use transitionReverseMode: 'separate_video' with a dedicated reverseVideoUrl for directional animations, or omit reverse mode entirely.
4. Preloading
Navigation elements drive preloading. Transition videos have highest priority (+150) and are preloaded first. The neighbor graph uses maxDepth: 1 (immediate neighbors only).
5. Instant Navigation with Preloaded Assets
When assets are preloaded, usePageSwitch uses getReadyBlobUrl(storageKey) for O(1) instant lookup of pre-decoded blob URLs, eliminating any delay or flash during navigation. Lookups prioritize storage keys (e.g., assets/project/bg.jpg) over resolved URLs because storage keys are canonical and don't change when presigned URLs are regenerated.