39948-vm/documentation/page-links-navigation.md
2026-07-03 16:11:24 +02:00

573 lines
22 KiB
Markdown

# 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:
```typescript
// 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.
```typescript
{
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:
```typescript
{
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` / `targetPageId` are cleared
- The URL opens in a new tab with `noopener,noreferrer`
- If the URL omits `http://` or `https://`, the runtime opens it with an `https://` 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.
```typescript
{
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:**
```typescript
{
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'`, uses `reverseVideoUrl` instead
- Page popped from navigation history
## Navigation Flow
### Runtime Resolution
**Files:**
- `frontend/src/components/RuntimePresentation.tsx`
- `frontend/src/lib/navigationHelpers.ts`
Navigation uses `resolveNavigationTarget` helper and `usePageSwitch` hook:
```typescript
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`):**
```typescript
// 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):
```typescript
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
```typescript
// 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:
1. `getReadyBlobUrl(storageKey)` → O(1) instant (same session)
2. `getCachedBlobUrl(storageKey)` → Cache API (~5ms, post-refresh)
3. Fallback to resolved URL lookup
### Reverse Playback
When navigating back with `transitionReverseMode = 'auto_reverse'`:
- Video plays from end to beginning using `useReversePlayback` hook
- Supports native `playbackRate = -1` or frame-stepping fallback
```typescript
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:
1. Select element type (`navigation_next` or `navigation_prev`)
2. Choose target page from dropdown (shows page names/slugs)
3. Optionally select transition video from assets
4. Configure transition settings (duration, reverse support)
```typescript
// 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:
```typescript
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:
```typescript
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`
```typescript
// 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`
```typescript
// 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:
```typescript
// 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.