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

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 / 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.

{
  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', 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:

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:

  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
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)
// 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,
};

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.