2062 lines
65 KiB
Markdown
2062 lines
65 KiB
Markdown
# Frontend Hooks Module
|
|
|
|
## Overview
|
|
|
|
The Hooks module provides **47 custom React hooks** for reusable logic across the frontend application. Hooks handle everything from runtime presentation (preloading, navigation, transitions) to admin pages (tables, forms, filters), the constructor (elements, drag-drop), and React Query data fetching.
|
|
|
|
**Location:** `frontend/src/hooks/`
|
|
|
|
**Total Files:** 56 TypeScript files (41 hooks + 13 query hooks + 2 index files)
|
|
|
|
**Recent Refactors:**
|
|
1. **Page Navigation Consolidation:** 6+ fragmented hooks consolidated into `usePageNavigationState` state machine. See [Navigation State Machine](#usepagenavigationstate) for details.
|
|
2. **Video Hooks Module:** New `hooks/video/` directory with 8 primitive hooks for composable video playback. See [Video Hooks Module](./video-hooks-module.md) for details.
|
|
3. **Preloading Simplification:** Removed neighbor preloading (stream-first approach). Constructor always uses online mode - transition videos stream on-demand, then cache for replay.
|
|
|
|
---
|
|
|
|
## State Management Context
|
|
|
|
**Redux is the default** for app-wide state management. These hooks complement Redux by handling:
|
|
- **Ephemeral state** - Transient UI states that reset on unmount
|
|
- **Computed/derived values** - Data derived from Redux state
|
|
- **DOM interactions** - Refs, observers, event handlers
|
|
- **Session-scoped state** - State that doesn't persist across routes
|
|
|
|
See [Stores Module - State Management Guidelines](./stores-module.md#state-management-guidelines) for the decision tree on when to use Redux vs local hooks.
|
|
|
|
**Example:** `usePageNavigation` uses local `useState` because page history is session-scoped and isolated to a single route. If cross-component access or persistence were needed, it would be a Redux slice.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
frontend/src/hooks/
|
|
├── index.ts # Central exports (tree-shaking friendly, 67 LOC)
|
|
│
|
|
├── Runtime/Preloading Hooks (10)
|
|
│ ├── usePreloadOrchestrator.ts # Main asset preloading coordinator (~720 LOC) [SIMPLIFIED]
|
|
│ ├── usePageNavigationState.ts # Unified page navigation state machine (~520 LOC)
|
|
│ ├── useTransitionPlayback.ts # Video transition playback (~795 LOC)
|
|
│ ├── useSlideTransition.ts # Slide transition animation (~180 LOC)
|
|
│ ├── usePageNavigation.ts # Page navigation state + history (~195 LOC)
|
|
│ ├── useBackgroundVideoPlayback.ts # Background video time control + play once (~225 LOC)
|
|
│ ├── useVideoSoundControl.ts # iOS autoplay sound control (~100 LOC)
|
|
│ ├── usePageDataLoader.ts # Project/page data loading (~253 LOC)
|
|
│ ├── usePageBackground.ts # Page background management (~176 LOC)
|
|
│ └── useCanvasScale.ts # Responsive canvas scaling (~110 LOC)
|
|
│
|
|
│ # DELETED (consolidated into usePageNavigationState):
|
|
│ # - usePageSwitch.ts (~475 LOC)
|
|
│ # - useBackgroundTransition.ts (~164 LOC)
|
|
│ # - useBackgroundUrls.ts (~104 LOC)
|
|
│
|
|
│ # DELETED (simplified preloading - no neighbor traversal):
|
|
│ # - useNeighborGraph.ts (~282 LOC)
|
|
│
|
|
├── Video Hooks (8)
|
|
│ ├── video/index.ts # Exports (~60 LOC)
|
|
│ ├── video/useVideoEventManager.ts # Event listener management (~90 LOC)
|
|
│ ├── video/useVideoBufferingState.ts # Buffering detection (~180 LOC)
|
|
│ ├── video/useVideoBlobUrl.ts # Blob URL resolution (~150 LOC)
|
|
│ ├── video/useVideoFirstFrame.ts # First frame detection (~130 LOC)
|
|
│ ├── video/useVideoErrorRecovery.ts # Error handling + retry (~160 LOC)
|
|
│ ├── video/useVideoTimeouts.ts # Timeout management (~80 LOC)
|
|
│ ├── video/useVideoPlaybackCore.ts # Composite playback logic (~280 LOC)
|
|
│ └── video/useVideoPlayer.ts # UI video player (~200 LOC)
|
|
│
|
|
├── Audio Hooks (2) - NEW
|
|
│ ├── audio/useAudioEventManager.ts # Audio event listener management (~115 LOC)
|
|
│ └── useBackgroundAudioPlayback.ts # Background audio playback + ducking (~220 LOC)
|
|
│
|
|
├── PWA/Offline Hooks (5)
|
|
│ ├── useOfflineMode.ts # PWA offline management (~468 LOC)
|
|
│ ├── usePWAPreload.ts # PWA asset preloading (~199 LOC)
|
|
│ ├── useStorageQuota.ts # Storage quota monitoring (~110 LOC)
|
|
│ ├── useNetworkAware.ts # Network condition monitoring (~163 LOC)
|
|
│ └── usePreloadProgress.ts # Preload progress tracking (~220 LOC)
|
|
│
|
|
├── Constructor Hooks (10)
|
|
│ ├── useConstructorElements.ts # Canvas element CRUD + local clipboard
|
|
│ ├── useCanvasElementDrag.ts # Element drag handling (~150 LOC)
|
|
│ ├── useConstructorPageActions.ts # Page save/create/duplicate support
|
|
│ ├── useConstructorData.ts # Constructor data loading (~150 LOC)
|
|
│ ├── useTransitionPreview.ts # Transition preview state (~184 LOC)
|
|
│ ├── useTransitionCreation.ts # Transition creation state (~134 LOC)
|
|
│ ├── useCanvasElapsedTime.ts # Canvas animation timing (~111 LOC)
|
|
│ ├── useMediaDurationProbe.ts # Media duration detection (~219 LOC)
|
|
│ └── useBackdropEffect.ts # Backdrop visual effects (~190 LOC)
|
|
│
|
|
├── Table/List Hooks (3)
|
|
│ ├── useEntityTable.ts # Complete table state management (~288 LOC)
|
|
│ ├── useFilterItems.ts # Filter state for data tables (~166 LOC)
|
|
│ └── useCSVHandling.ts # CSV upload/download operations (~141 LOC)
|
|
│
|
|
├── Form Hooks (2)
|
|
│ ├── useEditPageSync.ts # Edit page form sync (~166 LOC)
|
|
│ └── useFormSync.ts # Form state management (~108 LOC)
|
|
│
|
|
├── UI Utility Hooks (10)
|
|
│ ├── useDraggable.ts # Draggable panel management (~200 LOC)
|
|
│ ├── useOutsideClick.ts # Click outside detection (~88 LOC)
|
|
│ ├── useElementEffects.ts # Element interactive effects (~128 LOC)
|
|
│ ├── useIconPreload.ts # Icon preloading (~176 LOC)
|
|
│ ├── useDashboardCounts.ts # Dashboard entity counts (~308 LOC)
|
|
│ ├── useDevCompilationStatus.ts # Dev compilation status (~44 LOC)
|
|
│ ├── useProjectAssets.ts # Project asset URL resolution (~102 LOC)
|
|
│ ├── useAssetOptions.ts # Asset select options (~119 LOC)
|
|
│ ├── usePublishStatus.ts # Publish/save timestamp status (~113 LOC)
|
|
│ └── useAudioEffects.ts # Audio playback on hover/click (~305 LOC)
|
|
│
|
|
├── queries/ # React Query data fetching hooks (13)
|
|
│ ├── index.ts # Query exports (~59 LOC)
|
|
│ ├── useAccessLogsQuery.ts # Access logs query (~51 LOC)
|
|
│ ├── useAssetVariantsQuery.ts # Asset variants query (~43 LOC)
|
|
│ ├── useAssetsQuery.ts # Assets query (~109 LOC)
|
|
│ ├── useElementDefaultsQuery.ts # Element defaults query (~50 LOC)
|
|
│ ├── usePagesQuery.ts # Tour pages query (~115 LOC)
|
|
│ ├── usePermissionsQuery.ts # Permissions query (~35 LOC)
|
|
│ ├── useProjectAudioTracksQuery.ts # Audio tracks query (~109 LOC)
|
|
│ ├── useProjectMembershipsQuery.ts # Project memberships query (~94 LOC)
|
|
│ ├── useProjectQuery.ts # Project query (~84 LOC)
|
|
│ ├── usePublishEventsQuery.ts # Publish events query (~56 LOC)
|
|
│ ├── usePwaCachesQuery.ts # PWA caches query (~67 LOC)
|
|
│ ├── useRolesQuery.ts # Roles query (~112 LOC)
|
|
│ └── useUsersQuery.ts # Users query (~126 LOC)
|
|
│
|
|
└── Exports
|
|
└── index.ts # Tree-shaking friendly exports
|
|
```
|
|
|
|
---
|
|
|
|
## Hook Categories
|
|
|
|
### 1. Runtime/Preloading Hooks
|
|
|
|
These hooks power the runtime presentation viewer, handling asset preloading, page switching, and video transitions.
|
|
|
|
#### usePreloadOrchestrator
|
|
|
|
**File:** `usePreloadOrchestrator.ts` (~720 LOC)
|
|
|
|
**Purpose:** Main coordinator for asset preloading with priority queues and blob URL caching. Uses a **stream-first** approach - only preloads current page assets and outgoing transition videos.
|
|
|
|
```typescript
|
|
interface UsePreloadOrchestratorOptions {
|
|
pages: PreloadPage[];
|
|
pageLinks: PreloadPageLink[];
|
|
elements: PreloadElement[];
|
|
currentPageId: string | null;
|
|
pageHistory?: string[]; // Used for detecting back navigation
|
|
enabled?: boolean;
|
|
}
|
|
|
|
interface UsePreloadOrchestratorResult {
|
|
isPreloading: boolean;
|
|
currentPhase: 'idle' | 'phase1_current_page' | 'phase2_transitions';
|
|
preloadedUrls: Set<string>;
|
|
queueLength: number;
|
|
/** Version counter that increments when blob URLs become ready (triggers re-renders) */
|
|
readyUrlsVersion: number;
|
|
preloadAsset: (url: string, priority?: number) => void;
|
|
clearQueue: () => void;
|
|
getCachedBlobUrl: (url: string) => Promise<string | null>;
|
|
isUrlPreloaded: (url: string) => Promise<boolean>;
|
|
getReadyBlobUrl: (url: string) => string | null;
|
|
}
|
|
```
|
|
|
|
**Key Features:**
|
|
- **Stream-first approach:** Only preloads current page + outgoing transitions (no neighbor preloading)
|
|
- Priority-based preload queue (transition videos +150, images +100)
|
|
- Two phases: Phase 1 (current page backgrounds) → Phase 2 (transition videos)
|
|
- Dual storage strategy: Cache API (< 5MB) vs IndexedDB (>= 5MB)
|
|
- Presigned URL management with proxy fallback
|
|
- Image pre-decoding for instant display
|
|
- O(1) blob URL lookup via `readyBlobUrlsRef`
|
|
|
|
**Recent Simplification:**
|
|
The hook was refactored to remove neighbor graph traversal (previously used `useNeighborGraph`). This simplifies the system:
|
|
- Constructor always uses online mode
|
|
- Transition videos stream on-demand, then cache for replay
|
|
- Eliminates network contention from aggressive preloading
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const preloadOrchestrator = usePreloadOrchestrator({
|
|
pages,
|
|
pageLinks,
|
|
elements,
|
|
currentPageId,
|
|
pageHistory,
|
|
enabled: true,
|
|
});
|
|
|
|
// Get cached blob URL for instant display
|
|
const blobUrl = preloadOrchestrator.getReadyBlobUrl(originalUrl);
|
|
|
|
// Manually preload with high priority
|
|
preloadOrchestrator.preloadAsset(transitionVideoUrl, 150);
|
|
```
|
|
|
|
---
|
|
|
|
#### usePageNavigationState
|
|
|
|
**File:** `usePageNavigationState.ts` (~520 LOC)
|
|
|
|
**Purpose:** Unified state machine for page navigation using `useReducer` for atomic state transitions. Consolidates 6+ fragmented hooks to prevent race conditions.
|
|
|
|
**Consolidates:**
|
|
- `usePageSwitch` - URL resolution and switching
|
|
- `useBackgroundState` - Background ready tracking
|
|
- `useBackgroundTransition` - Fade-from-black effects
|
|
- `useTransitionCleanup` - Video cleanup coordination
|
|
- `useBackgroundUrls` - URL resolution for display
|
|
- `pageLoadingUtils` - Loading state computation
|
|
|
|
**State Machine Phases:**
|
|
```
|
|
┌──────────────────────────────────────────────────────────────────┐
|
|
│ PAGE NAVIGATION STATE MACHINE │
|
|
├──────────────────────────────────────────────────────────────────┤
|
|
│ ┌───────┐ navigate() ┌───────────┐ │
|
|
│ │ IDLE │ ─────────────► │ PREPARING │ │
|
|
│ └───────┘ └─────┬─────┘ │
|
|
│ ▲ YES / \ NO (has transition?) │
|
|
│ │ ▼ ▼ │
|
|
│ │ ┌─────────────┐ ┌────────────┐ │
|
|
│ │ │TRANSITIONING│ │ LOADING_BG │ │
|
|
│ │ └──────┬──────┘ └─────┬──────┘ │
|
|
│ │ video ends onBackgroundReady │
|
|
│ │ ▼ │ │
|
|
│ │ ┌─────────────┐ │ │
|
|
│ │ │TRANS_DONE │ │ │
|
|
│ │ └──────┬──────┘ │ │
|
|
│ │ onBackgroundReady │ │
|
|
│ │ ▼ ▼ │
|
|
│ │ ┌────────────────────────┐ │
|
|
│ │ │ FADING_IN │ │
|
|
│ │ └───────────┬────────────┘ │
|
|
│ │ fade complete │
|
|
│ └────────────────────────┘ │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
```typescript
|
|
type NavigationPhase =
|
|
| 'idle' // No navigation in progress
|
|
| 'preparing' // Resolving URLs, saving previous state
|
|
| 'transitioning' // Video transition playing
|
|
| 'transition_done'// Video finished, waiting for background
|
|
| 'loading_bg' // Direct navigation, waiting for background
|
|
| 'fading_in'; // Black overlay fading out
|
|
|
|
interface UsePageNavigationStateResult {
|
|
// Current state
|
|
phase: NavigationPhase;
|
|
|
|
// Current page URLs (for display)
|
|
currentImageUrl: string;
|
|
currentVideoUrl: string;
|
|
currentAudioUrl: string;
|
|
|
|
// Previous page URLs (for overlay)
|
|
previousImageUrl: string;
|
|
previousVideoUrl: string;
|
|
|
|
// Derived states (computed from phase)
|
|
isLoading: boolean;
|
|
showSpinner: boolean;
|
|
showElements: boolean;
|
|
showPreviousOverlay: boolean;
|
|
showTransitionVideo: boolean;
|
|
isFadingIn: boolean;
|
|
isSwitching: boolean;
|
|
isNewBgReady: boolean;
|
|
isBackgroundReady: boolean;
|
|
pendingTransitionComplete: boolean;
|
|
|
|
// Transition style for CSS
|
|
transitionStyle: React.CSSProperties;
|
|
|
|
// Actions
|
|
navigateToPage: (targetPage, options?) => Promise<void>;
|
|
onBackgroundReady: () => void;
|
|
onTransitionEnded: () => void;
|
|
setBackgroundDirectly: (imageUrl, videoUrl, audioUrl) => void;
|
|
onVideoBufferStateChange: (isBuffering: boolean) => void;
|
|
}
|
|
```
|
|
|
|
**Key Benefits:**
|
|
| Before | After |
|
|
|--------|-------|
|
|
| 6+ hooks with 15+ state variables | 1 hook with 1 state object |
|
|
| Race conditions from async setState | Atomic transitions via useReducer |
|
|
| Invalid flag combinations possible | State machine prevents invalid states |
|
|
| Derived state scattered | All derived state in one useMemo |
|
|
| Hard to debug | Single phase value to inspect |
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const navState = usePageNavigationState({
|
|
preloadCache: preloadOrchestrator
|
|
? {
|
|
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
}
|
|
: undefined,
|
|
transitionSettings,
|
|
});
|
|
|
|
// Navigate to a page
|
|
await navState.navigateToPage(targetPage, {
|
|
hasTransition: false,
|
|
isBack: false,
|
|
onSwitched: () => applyPageSelection(targetPage.id, false),
|
|
});
|
|
|
|
// Use in CanvasBackground
|
|
<CanvasBackground
|
|
backgroundImageUrl={navState.currentImageUrl}
|
|
backgroundVideoUrl={navState.currentVideoUrl}
|
|
previousBgImageUrl={navState.previousImageUrl}
|
|
isSwitching={navState.isSwitching}
|
|
isNewBgReady={navState.isNewBgReady}
|
|
onBackgroundReady={navState.onBackgroundReady}
|
|
/>
|
|
```
|
|
|
|
**Context Provider:**
|
|
A `PageNavigationContext` is available for components that need access to navigation state:
|
|
|
|
```typescript
|
|
import { PageNavigationProvider, usePageNavigationContext } from '../context/PageNavigationContext';
|
|
|
|
// In parent component
|
|
<PageNavigationProvider preloadCache={...} transitionSettings={...}>
|
|
<CanvasBackground />
|
|
<Elements />
|
|
</PageNavigationProvider>
|
|
|
|
// In child component
|
|
const { phase, onBackgroundReady } = usePageNavigationContext();
|
|
```
|
|
|
|
---
|
|
|
|
#### useTransitionPlayback
|
|
|
|
**File:** `useTransitionPlayback.ts` (~795 LOC)
|
|
|
|
**Purpose:** Coordinates video transition playback with forward and pre-generated reversed video support.
|
|
|
|
```typescript
|
|
interface TransitionPlaybackOptions {
|
|
transition: TransitionPreviewState | null;
|
|
pendingPageId: string;
|
|
transitionVideoRef: React.RefObject<HTMLVideoElement | null>;
|
|
preloadOrchestrator?: UsePreloadOrchestratorResult;
|
|
onTransitionComplete: (targetPageId: string) => void;
|
|
onTransitionError?: (error: string) => void;
|
|
}
|
|
|
|
interface TransitionPlaybackResult {
|
|
isPlaying: boolean;
|
|
play: () => Promise<void>;
|
|
stop: () => void;
|
|
pendingTransitionComplete: boolean;
|
|
}
|
|
```
|
|
|
|
**Reverse Modes:**
|
|
| Mode | Description |
|
|
|------|-------------|
|
|
| `none` | Forward playback only |
|
|
| `separate` | Use pre-generated reversed video (server-side FFmpeg reversal) |
|
|
|
|
Reversed videos are pre-generated on the server when pages are saved, using
|
|
FFmpeg for audio/video synchronization.
|
|
|
|
**Source URL Selection:**
|
|
```typescript
|
|
const sourceUrl = useMemo(() => {
|
|
if (!transition) return '';
|
|
// Use reversed video if back navigation with separate reversed video
|
|
if (transition.isBack && transition.reverseVideoUrl) {
|
|
return transition.reverseVideoUrl;
|
|
}
|
|
return transition.videoUrl;
|
|
}, [transition]);
|
|
```
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const transitionPlayback = useTransitionPlayback({
|
|
transition: transitionPreview,
|
|
pendingPageId,
|
|
transitionVideoRef,
|
|
preloadOrchestrator,
|
|
onTransitionComplete: (targetId) => {
|
|
pageSwitch.switchToPage(pages.find(p => p.id === targetId));
|
|
transitionPreviewHook.closePreview();
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
#### useSlideTransition
|
|
|
|
**File:** `useSlideTransition.ts` (~180 LOC)
|
|
|
|
**Purpose:** Manages slide transition animation state for Gallery and Carousel elements. Implements fade-through-overlay animation pattern.
|
|
|
|
**Animation Phases:**
|
|
```
|
|
Phase 1: Fade Out (half duration)
|
|
├── overlayOpacity: 0 → 1
|
|
└── slideOpacity: 1 → 0
|
|
|
|
Phase 2: Swap (at midpoint)
|
|
└── displayIndex switches to new slide
|
|
|
|
Phase 3: Fade In (half duration)
|
|
├── overlayOpacity: 1 → 0
|
|
└── slideOpacity: 0 → 1
|
|
```
|
|
|
|
```typescript
|
|
interface UseSlideTransitionReturn {
|
|
currentIndex: number; // Logical current index
|
|
displayIndex: number; // Visual index (lags during transition)
|
|
phase: 'idle' | 'fadingOut' | 'fadingIn';
|
|
isTransitioning: boolean;
|
|
overlayOpacity: number; // 0-1
|
|
overlayColor: string; // From settings
|
|
goToIndex: (index: number) => void;
|
|
setInitialIndex: (index: number) => void;
|
|
slideTransitionStyle: CSSProperties;
|
|
overlayTransitionStyle: CSSProperties;
|
|
slideOpacity: number; // Current slide visibility
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
```typescript
|
|
import { useSlideTransition } from '../hooks/useSlideTransition';
|
|
import { resolveSlideTransition, extractCarouselSlideOverride } from '../lib/resolveSlideTransition';
|
|
|
|
// Resolve settings with cascade
|
|
const slideSettings = resolveSlideTransition(
|
|
pageTransitionSettings,
|
|
extractCarouselSlideOverride(element),
|
|
);
|
|
|
|
const {
|
|
displayIndex,
|
|
goToIndex,
|
|
overlayOpacity,
|
|
overlayColor,
|
|
slideTransitionStyle,
|
|
overlayTransitionStyle,
|
|
slideOpacity,
|
|
} = useSlideTransition(slideSettings);
|
|
|
|
// Navigate
|
|
const handleNext = () => goToIndex((currentIndex + 1) % slides.length);
|
|
|
|
// Render
|
|
<img style={{ ...slideTransitionStyle, opacity: slideOpacity }} src={slides[displayIndex].imageUrl} />
|
|
<div style={{ ...overlayTransitionStyle, opacity: overlayOpacity, backgroundColor: overlayColor }} />
|
|
```
|
|
|
|
---
|
|
|
|
> **Note:** Runtime preloading is stream-first: the system preloads current page
|
|
> assets and outgoing transition videos.
|
|
|
|
---
|
|
|
|
#### usePageNavigation
|
|
|
|
**File:** `usePageNavigation.ts` (~195 LOC)
|
|
|
|
**Purpose:** Manages page navigation state with optional history tracking. Used by both `RuntimePresentation.tsx` and `constructor.tsx` for unified history management.
|
|
|
|
```typescript
|
|
interface UsePageNavigationResult<TPage extends NavigablePage> {
|
|
currentPageId: string | null;
|
|
currentPage: TPage | null;
|
|
pageHistory: string[];
|
|
previousPageId: string | null;
|
|
defaultPage: TPage | null;
|
|
setCurrentPageId: (pageId: string) => void;
|
|
applyPageSelection: (targetPageId: string, isBack?: boolean) => void;
|
|
isBackNavigation: (targetPageId: string) => boolean;
|
|
goBack: () => boolean;
|
|
resetHistory: () => void;
|
|
/** Get navigation context for history-based back navigation */
|
|
getNavigationContext: () => NavigationContext;
|
|
}
|
|
```
|
|
|
|
**Key Features:**
|
|
- **History limit**: `MAX_HISTORY_LENGTH = 50` prevents unbounded growth in long sessions
|
|
- **Browser-like back navigation**: History pops when `isBack=true` and target matches previous page
|
|
- **Navigation context**: `getNavigationContext()` provides `{ currentPageSlug, previousPageId }` for `resolveNavigationTarget()`
|
|
- **Unified state management**: Single source of truth for both constructor and runtime presentation
|
|
|
|
**Usage:**
|
|
```typescript
|
|
// Runtime mode with history (RuntimePresentation.tsx)
|
|
const {
|
|
currentPageId: selectedPageId,
|
|
pageHistory,
|
|
applyPageSelection,
|
|
getNavigationContext,
|
|
} = usePageNavigation({
|
|
pages,
|
|
defaultPageId: initialPageId,
|
|
trackHistory: true,
|
|
});
|
|
|
|
// Constructor mode with history (constructor.tsx)
|
|
const {
|
|
currentPageId: activePageId,
|
|
pageHistory,
|
|
applyPageSelection,
|
|
getNavigationContext,
|
|
setCurrentPageId: setActivePageId,
|
|
} = usePageNavigation({
|
|
pages,
|
|
trackHistory: true,
|
|
});
|
|
|
|
// Navigate with back flag (history pops on back)
|
|
applyPageSelection(targetPageId, isBack);
|
|
|
|
// Get context for history-based navigation
|
|
const navContext = getNavigationContext();
|
|
const navTarget = resolveNavigationTarget(element, pages, navContext);
|
|
|
|
// Go back programmatically
|
|
if (nav.goBack()) {
|
|
console.log('Went back to', nav.previousPageId);
|
|
}
|
|
```
|
|
|
|
**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
|
|
```
|
|
|
|
---
|
|
|
|
> **Note:** `useBackgroundTransition` has been consolidated into `usePageNavigationState`. See [usePageNavigationState](#usepagenavigationstate) for the unified state machine.
|
|
|
|
---
|
|
|
|
#### useBackgroundVideoPlayback
|
|
|
|
**File:** `useBackgroundVideoPlayback.ts` (~225 LOC)
|
|
|
|
**Purpose:** Manages background video playback with custom start/end time control and "play once" functionality for tour pages.
|
|
|
|
```typescript
|
|
interface UseBackgroundVideoPlaybackOptions {
|
|
videoUrl?: string;
|
|
autoplay?: boolean; // Default: true
|
|
loop?: boolean; // Default: true
|
|
muted?: boolean; // Default: true
|
|
startTime?: number | null; // Start playback at this time (seconds)
|
|
endTime?: number | null; // Stop/loop at this time (seconds)
|
|
playOnce?: boolean; // Default: false - play only once per session
|
|
pageId?: string; // Required for playOnce tracking
|
|
}
|
|
|
|
interface UseBackgroundVideoPlaybackResult {
|
|
videoRef: RefObject<HTMLVideoElement | null>;
|
|
}
|
|
```
|
|
|
|
**Key Features:**
|
|
- Handles `loadedmetadata` event for initial seek to `startTime`
|
|
- Uses `timeupdate` event to enforce `endTime` boundary
|
|
- When `endTime` is set, handles looping via JavaScript (seeks back to `startTime`)
|
|
- Autoplay with browser policy handling (catch and ignore)
|
|
- **Play Once Mode:** When `playOnce` is true and `pageId` is provided:
|
|
- Tracks played pages in a module-level `Set<string>` (`playedOncePages`)
|
|
- On first visit: video plays normally, page ID added to Set on `ended` event
|
|
- On subsequent visits: video seeks to last frame and pauses immediately
|
|
- Session-scoped (resets on browser refresh, not persisted to storage)
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const { videoRef } = useBackgroundVideoPlayback({
|
|
videoUrl: page.background_video_url,
|
|
autoplay: page.background_video_autoplay ?? true,
|
|
loop: page.background_video_loop ?? true,
|
|
muted: page.background_video_muted ?? true,
|
|
startTime: page.background_video_start_time ?? null,
|
|
endTime: page.background_video_end_time ?? null,
|
|
playOnce: page.background_video_play_once ?? false,
|
|
pageId: page.id, // Required for play-once tracking
|
|
});
|
|
|
|
// Render video with ref
|
|
<video
|
|
ref={videoRef}
|
|
src={videoUrl}
|
|
autoPlay={autoplay}
|
|
loop={endTime == null ? loop : false} // Use JS loop when endTime is set
|
|
muted={muted}
|
|
playsInline
|
|
/>
|
|
```
|
|
|
|
**Note:** When `endTime` is set, native HTML5 `loop` attribute should be disabled to allow the hook to handle looping via `timeupdate` event, properly seeking back to `startTime`.
|
|
|
|
---
|
|
|
|
#### useVideoSoundControl
|
|
|
|
**File:** `useVideoSoundControl.ts` (~100 LOC)
|
|
|
|
**Purpose:** Manages global presentation sound state with iOS autoplay compatibility. Presentation audio starts muted so visual media can autoplay, then background audio/video, element hover/click effects, media players, and gallery/info-panel videos can be unmuted by user interaction via a custom sound button.
|
|
|
|
```typescript
|
|
interface UseVideoSoundControlOptions {
|
|
pageHasSound: boolean; // Whether page settings allow sound
|
|
hasBackgroundVideo: boolean; // Whether page has a background video
|
|
hasBackgroundAudio?: boolean; // Whether page has ambient/background audio
|
|
hasElementAudio?: boolean; // Whether elements can produce sound
|
|
hasPresentationAudio?: boolean; // Whether any page in the presentation can produce sound
|
|
}
|
|
|
|
interface UseVideoSoundControlResult {
|
|
isMuted: boolean; // Current muted state (starts true)
|
|
showSoundButton: boolean; // Whether to show sound toggle button
|
|
toggleSound: () => void; // Toggle muted state
|
|
setMuted: (muted: boolean) => void; // Force muted state
|
|
}
|
|
```
|
|
|
|
**Key Features:**
|
|
- Always starts muted for iOS autoplay compatibility
|
|
- Shows sound button when background video sound, background audio, element
|
|
audio/video sound, or presentation-level audio exists
|
|
- Works with `RuntimeControls` sound toggle button
|
|
- Used by runtime presentations and constructor interact mode
|
|
|
|
**Usage:**
|
|
```tsx
|
|
const soundControl = useVideoSoundControl({
|
|
pageHasSound: page.background_video_muted === false,
|
|
hasBackgroundVideo: Boolean(backgroundVideoUrl),
|
|
hasBackgroundAudio: Boolean(backgroundAudioUrl),
|
|
hasPresentationAudio,
|
|
});
|
|
|
|
// Pass to CanvasBackground (always starts muted)
|
|
<CanvasBackground videoMuted={soundControl.isMuted} />
|
|
|
|
// Pass to RuntimeControls (sound toggle button)
|
|
<RuntimeControls
|
|
showSoundButton={soundControl.showSoundButton}
|
|
isMuted={soundControl.isMuted}
|
|
onSoundToggle={soundControl.toggleSound}
|
|
/>
|
|
```
|
|
|
|
**iOS Autoplay Policy:**
|
|
- iOS WebKit blocks autoplay for unmuted videos
|
|
- By starting muted + providing custom sound button, native controls are avoided
|
|
- CSS in `main.css` hides native `-webkit-media-controls-*` as fallback
|
|
|
|
---
|
|
|
|
### Audio Hooks
|
|
|
|
#### useAudioEventManager
|
|
|
|
**File:** `audio/useAudioEventManager.ts` (~115 LOC)
|
|
|
|
**Purpose:** Manages audio element event listener setup and cleanup with a declarative API. Mirrors the `useVideoEventManager` pattern for consistency.
|
|
|
|
```typescript
|
|
interface UseAudioEventManagerOptions {
|
|
audioRef: RefObject<HTMLAudioElement | null>;
|
|
enabled?: boolean;
|
|
handlers: AudioEventHandlers;
|
|
}
|
|
|
|
interface AudioEventHandlers {
|
|
onLoadedMetadata?: (event: Event) => void;
|
|
onLoadedData?: (event: Event) => void;
|
|
onCanPlay?: (event: Event) => void;
|
|
onPlaying?: (event: Event) => void;
|
|
onPause?: (event: Event) => void;
|
|
onEnded?: (event: Event) => void;
|
|
onTimeUpdate?: (event: Event) => void;
|
|
onError?: (event: Event) => void;
|
|
// ... other audio events
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
```typescript
|
|
useAudioEventManager({
|
|
audioRef,
|
|
enabled: Boolean(audioUrl),
|
|
handlers: {
|
|
onLoadedMetadata: () => seekToStartTime(),
|
|
onTimeUpdate: () => enforceEndTime(),
|
|
onEnded: () => markAsPlayed(),
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
#### useBackgroundAudioPlayback
|
|
|
|
**File:** `useBackgroundAudioPlayback.ts` (~220 LOC)
|
|
|
|
**Purpose:** Manages background audio playback with custom start/end time control, session-scoped "play once" tracking, and integration with audio ducking system.
|
|
|
|
```typescript
|
|
interface UseBackgroundAudioPlaybackOptions {
|
|
audioUrl?: string; // Audio URL (may be blob URL)
|
|
audioStoragePath?: string; // Original storage path for play-once tracking
|
|
autoplay?: boolean; // Default: true
|
|
loop?: boolean; // Default: true
|
|
startTime?: number | null; // Start playback at time (seconds)
|
|
endTime?: number | null; // Stop/loop at time (seconds)
|
|
paused?: boolean; // External pause control (for ducking)
|
|
}
|
|
|
|
interface UseBackgroundAudioPlaybackResult {
|
|
audioRef: RefObject<HTMLAudioElement | null>;
|
|
shouldBlockAutoplay: boolean; // True if audio already played this session
|
|
}
|
|
```
|
|
|
|
**Key Features:**
|
|
- **Start/End Time Control:** Seeks to `startTime` on load, loops or pauses at `endTime`
|
|
- **Play Once Tracking:** Session-scoped Set tracks played audio (cleared on browser refresh)
|
|
- **Audio Ducking Integration:** Registers with `backgroundAudioController` for automatic pause/resume when element audio plays
|
|
- **Graceful Autoplay:** Handles browser autoplay restrictions silently
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const { audioRef } = useBackgroundAudioPlayback({
|
|
audioUrl: page.background_audio_url,
|
|
autoplay: page.background_audio_autoplay ?? true,
|
|
loop: page.background_audio_loop ?? true,
|
|
startTime: page.background_audio_start_time ?? null,
|
|
endTime: page.background_audio_end_time ?? null,
|
|
});
|
|
|
|
// Render audio with ref
|
|
<audio ref={audioRef} src={audioUrl} preload="auto" hidden />
|
|
```
|
|
|
|
**Audio Ducking:** When element hover/click audio starts playing, background audio is automatically paused via `backgroundAudioController`. When element audio ends, background audio resumes.
|
|
|
|
---
|
|
|
|
#### useCanvasScale
|
|
|
|
**File:** `useCanvasScale.ts` (~110 LOC)
|
|
|
|
**Purpose:** Manages responsive canvas scaling with letterbox mode for maintaining design aspect ratio across different viewport sizes. Core hook of the UI Adaptivity System.
|
|
|
|
> **See:** [UI Adaptivity System](ui-adaptivity-system.md) for complete documentation including canvas units, letterbox mode, and element styling.
|
|
|
|
```typescript
|
|
interface UseCanvasScaleOptions {
|
|
designWidth?: number; // Design canvas width (default: 1920)
|
|
designHeight?: number; // Design canvas height (default: 1080)
|
|
}
|
|
|
|
interface UseCanvasScaleResult {
|
|
scale: number; // Current scale factor (1.0 = design size)
|
|
designWidth: number; // Design width (from options or default)
|
|
designHeight: number; // Design height (from options or default)
|
|
canvasWidth: number; // Actual canvas width in pixels
|
|
canvasHeight: number; // Actual canvas height in pixels
|
|
isPortrait: boolean; // Whether viewport is portrait orientation
|
|
showRotatePrompt: boolean; // Whether to show rotate prompt
|
|
cssVars: CSSProperties; // CSS custom properties for scaling
|
|
letterboxStyles: CSSProperties; // Styles for centered letterbox container
|
|
}
|
|
```
|
|
|
|
**Key Features:**
|
|
- Calculates scale factor: `min(viewportWidth/designWidth, viewportHeight/designHeight)`
|
|
- Provides CSS custom properties (`--cu`, `--canvas-scale`, `--design-width`, `--design-height`)
|
|
- Generates letterbox styles for centered canvas with black bars
|
|
- Handles portrait orientation detection with rotate prompt
|
|
|
|
**CSS Custom Properties:**
|
|
| Property | Description | Example |
|
|
|----------|-------------|---------|
|
|
| `--cu` | Canvas unit (1 design pixel) | `calc(1px * 0.75)` |
|
|
| `--canvas-scale` | Current scale factor | `0.75` |
|
|
| `--design-width` | Design canvas width | `1920` |
|
|
| `--design-height` | Design canvas height | `1080` |
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const { cssVars, letterboxStyles, isPortrait, showRotatePrompt } = useCanvasScale({
|
|
designWidth: project?.design_width,
|
|
designHeight: project?.design_height,
|
|
});
|
|
|
|
// Apply to canvas container
|
|
<div className="bg-black w-screen h-screen overflow-hidden">
|
|
<div style={{ ...cssVars, ...letterboxStyles }}>
|
|
{/* Canvas content - all children can use var(--cu) */}
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**Integration with Element Styles:**
|
|
```typescript
|
|
import { toCU } from '../lib/canvasScale';
|
|
|
|
// Elements use canvas units for responsive sizing
|
|
<button style={{
|
|
fontSize: toCU(16), // "calc(16 * var(--cu, 1px))"
|
|
padding: toCU(12), // Scales with viewport
|
|
borderRadius: toCU(8),
|
|
}}>
|
|
Click me
|
|
</button>
|
|
```
|
|
|
|
---
|
|
|
|
#### usePageDataLoader
|
|
|
|
**File:** `usePageDataLoader.ts` (~253 LOC)
|
|
|
|
**Purpose:** Loads project and page data for runtime and constructor.
|
|
|
|
```typescript
|
|
interface UsePageDataLoaderResult {
|
|
project: Project | null;
|
|
pages: TourPage[];
|
|
elements: CanvasElement[];
|
|
pageLinks: PreloadPageLink[];
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
reload: (preservePageId?: string) => Promise<void>;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. PWA/Offline Hooks
|
|
|
|
Hooks for Progressive Web App functionality including offline caching and network monitoring.
|
|
|
|
#### useOfflineMode
|
|
|
|
**File:** `useOfflineMode.ts` (~468 LOC)
|
|
|
|
**Purpose:** Full offline mode management with download progress tracking.
|
|
|
|
```typescript
|
|
interface UseOfflineModeOptions {
|
|
projectId: string | null;
|
|
projectSlug?: string;
|
|
projectName?: string;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
interface UseOfflineModeResult {
|
|
// Status
|
|
isOfflineCapable: boolean;
|
|
isDownloaded: boolean;
|
|
isDownloading: boolean;
|
|
status: ProjectOfflineStatus;
|
|
progress: number;
|
|
downloadedAssets: number;
|
|
totalAssets: number;
|
|
downloadedBytes: number;
|
|
totalBytes: number;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
startDownload: () => Promise<void>;
|
|
pauseDownload: () => void;
|
|
resumeDownload: () => void;
|
|
cancelDownload: () => void;
|
|
deleteOfflineData: () => Promise<void>;
|
|
checkForUpdates: () => Promise<boolean>;
|
|
|
|
// Info
|
|
projectInfo: OfflineProject | null;
|
|
estimatedSize: number;
|
|
formatSize: (bytes: number) => string;
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const offlineMode = useOfflineMode({
|
|
projectId,
|
|
projectSlug,
|
|
projectName,
|
|
enabled: true,
|
|
});
|
|
|
|
// Start download
|
|
await offlineMode.startDownload();
|
|
|
|
// Check progress
|
|
console.log(`Downloaded: ${offlineMode.progress}%`);
|
|
console.log(`${offlineMode.downloadedAssets}/${offlineMode.totalAssets} assets`);
|
|
console.log(`Size: ${offlineMode.formatSize(offlineMode.downloadedBytes)}`);
|
|
```
|
|
|
|
---
|
|
|
|
#### useStorageQuota
|
|
|
|
**File:** `useStorageQuota.ts` (~110 LOC)
|
|
|
|
**Purpose:** Monitors storage quota and usage for offline assets.
|
|
|
|
```typescript
|
|
interface UseStorageQuotaResult extends StorageQuotaInfo {
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
refresh: () => Promise<void>;
|
|
requestPersistence: () => Promise<boolean>;
|
|
isPersisted: boolean;
|
|
isWarning: boolean; // >= warningPercent (default 70%)
|
|
isCritical: boolean; // >= criticalPercent (default 90%)
|
|
formatSize: (bytes: number) => string;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
#### useNetworkAware
|
|
|
|
**File:** `useNetworkAware.ts` (~163 LOC)
|
|
|
|
**Purpose:** Monitors network conditions and adapts preloading strategy.
|
|
|
|
```typescript
|
|
interface UseNetworkAwareResult {
|
|
networkInfo: NetworkInfo;
|
|
shouldPreloadAggressively: boolean; // 4G or high downlink
|
|
preferLowQuality: boolean; // slow-2g, 2g, or saveData
|
|
recommendedConcurrency: number; // 1-3 based on connection
|
|
suggestOfflineMode: boolean; // Poor connection detected
|
|
}
|
|
```
|
|
|
|
**Network Concurrency Table:**
|
|
| Connection | Concurrency |
|
|
|------------|-------------|
|
|
| slow-2g | 1 |
|
|
| 2g | 1 |
|
|
| 3g | 2 |
|
|
| 4g | 3 |
|
|
|
|
---
|
|
|
|
#### usePWAPreload
|
|
|
|
**File:** `usePWAPreload.ts` (~199 LOC)
|
|
|
|
**Purpose:** Orchestrates asset preloading for PWA offline caching with progress tracking.
|
|
|
|
```typescript
|
|
interface PreloadState {
|
|
isPreloading: boolean;
|
|
progress: number;
|
|
loadedCount: number;
|
|
totalCount: number;
|
|
errors: string[];
|
|
}
|
|
|
|
const result = usePWAPreload({
|
|
assets: [
|
|
{ url: 'image.jpg', type: 'image' },
|
|
{ url: 'video.mp4', type: 'video' },
|
|
],
|
|
onComplete: () => console.log('Done!'),
|
|
skipIfCached: true,
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### 3. Constructor Hooks
|
|
|
|
Hooks for the visual tour builder (constructor.tsx).
|
|
|
|
#### useConstructorElements
|
|
|
|
**File:** `useConstructorElements.ts`
|
|
|
|
**Purpose:** Canvas element CRUD operations with selection management, nested
|
|
gallery/carousel/info-panel item helpers, and constructor-local element
|
|
copy/paste clipboard.
|
|
|
|
```typescript
|
|
interface UseConstructorElementsOptions {
|
|
initialElements?: CanvasElement[];
|
|
elementDefaultsByType?: Partial<Record<CanvasElementType, Partial<CanvasElement>>>;
|
|
allowedNavigationTypes?: NavigationElementType[];
|
|
onElementsChange?: (elements: CanvasElement[]) => void;
|
|
initialSelectedElementId?: string;
|
|
onElementSelected?: (elementId: string) => void;
|
|
onSelectionCleared?: () => void;
|
|
onElementAdded?: (element: CanvasElement) => void;
|
|
onElementRemoved?: (elementId: string) => void;
|
|
onElementCopied?: (element: CanvasElement) => void;
|
|
onElementPasted?: (element: CanvasElement) => void;
|
|
}
|
|
|
|
interface UseConstructorElementsResult {
|
|
elements: CanvasElement[];
|
|
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
|
|
getElements: () => CanvasElement[];
|
|
selectedElementId: string;
|
|
selectedElement: CanvasElement | null;
|
|
copiedElement: CanvasElement | null;
|
|
canPasteElement: boolean;
|
|
selectElement: (elementId: string) => void;
|
|
clearSelection: () => void;
|
|
addElement: (type: CanvasElementType) => void;
|
|
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
|
|
updateElement: (elementId: string, patch: Partial<CanvasElement>) => void;
|
|
removeSelectedElement: () => void;
|
|
copySelectedElement: () => void;
|
|
pasteCopiedElement: () => CanvasElement | null;
|
|
removeElement: (elementId: string) => void;
|
|
|
|
// Gallery cards
|
|
galleryCards: {
|
|
add: () => void;
|
|
update: (cardId: string, patch: Partial<GalleryCard>) => void;
|
|
remove: (cardId: string) => void;
|
|
};
|
|
|
|
// Gallery info spans
|
|
galleryInfoSpans: {
|
|
add: () => void;
|
|
update: (spanId: string, text: string) => void;
|
|
remove: (spanId: string) => void;
|
|
};
|
|
|
|
// Carousel slides
|
|
carouselSlides: {
|
|
add: () => void;
|
|
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
|
remove: (slideId: string) => void;
|
|
};
|
|
|
|
updateElementPosition: (elementId: string, xPercent: number, yPercent: number) => void;
|
|
bringToFront: (elementId: string) => void;
|
|
sendToBack: (elementId: string) => void;
|
|
}
|
|
```
|
|
|
|
`setElements` is wrapped by the hook so it updates both React state and an
|
|
internal `elementsRef`. `getElements()` reads that ref synchronously. This is
|
|
required for same-tick flows such as Paste followed immediately by Save or Save
|
|
to Stage, where React may not have committed the visible re-render yet.
|
|
|
|
Element copy/paste uses `cloneElementForPaste()` from `lib/elementDefaults.ts`.
|
|
The clone preserves all non-identity settings, including CSS styles, effects,
|
|
media, links, navigation targets, transition fields, dimensions, and position.
|
|
Only the top-level element ID and nested gallery/carousel/info-panel item IDs are
|
|
regenerated.
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const {
|
|
elements,
|
|
getElements,
|
|
selectedElement,
|
|
addElement,
|
|
updateSelectedElement,
|
|
updateElement,
|
|
copySelectedElement,
|
|
pasteCopiedElement,
|
|
canPasteElement,
|
|
galleryCards,
|
|
galleryInfoSpans,
|
|
} = useConstructorElements({
|
|
initialElements,
|
|
onElementsChange: setElements,
|
|
elementDefaultsByType,
|
|
});
|
|
|
|
// Add a button element
|
|
addElement('button');
|
|
|
|
// Update selected element
|
|
updateSelectedElement({ label: 'New Label' });
|
|
|
|
// Update element by ID
|
|
updateElement(elementId, { xPercent: 50 });
|
|
|
|
// Add gallery card to selected gallery
|
|
galleryCards.add();
|
|
|
|
// Add info span to selected gallery
|
|
galleryInfoSpans.add();
|
|
|
|
// Toolbar copy/paste actions
|
|
copySelectedElement();
|
|
if (canPasteElement) pasteCopiedElement();
|
|
```
|
|
|
|
---
|
|
|
|
#### useCanvasElementDrag
|
|
|
|
**File:** `useCanvasElementDrag.ts` (~150 LOC)
|
|
|
|
**Purpose:** Handles element dragging on the constructor canvas.
|
|
|
|
```typescript
|
|
interface UseCanvasElementDragResult {
|
|
isDragging: boolean;
|
|
onDragStart: (event: React.MouseEvent, elementId: string) => void;
|
|
onDragEnd: () => void;
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const { isDragging, onDragStart, onDragEnd } = useCanvasElementDrag({
|
|
canvasRef,
|
|
elements,
|
|
onPositionChange: (elementId, xPercent, yPercent) => {
|
|
updateElementPosition(elementId, xPercent, yPercent);
|
|
},
|
|
});
|
|
|
|
// Attach to element
|
|
<div
|
|
onMouseDown={(e) => onDragStart(e, element.id)}
|
|
onMouseUp={onDragEnd}
|
|
>
|
|
```
|
|
|
|
---
|
|
|
|
#### useConstructorPageActions
|
|
|
|
**File:** `useConstructorPageActions.ts`
|
|
|
|
**Purpose:** Page create/save/publish operations in constructor, including page
|
|
duplication orchestration.
|
|
|
|
```typescript
|
|
interface UseConstructorPageActionsOptions {
|
|
activePageId: string;
|
|
elements: CanvasElement[];
|
|
getElements?: () => CanvasElement[];
|
|
pageBackground: PageBackgroundState;
|
|
onReload: () => Promise<void>;
|
|
}
|
|
|
|
interface UseConstructorPageActionsResult {
|
|
isSaving: boolean;
|
|
isSavingToStage: boolean;
|
|
isCreatingPage: boolean;
|
|
isDuplicatingPage: boolean;
|
|
isCreatingTransition: boolean;
|
|
saveConstructor: () => Promise<void>;
|
|
saveToStage: () => Promise<void>;
|
|
createPage: () => Promise<void>;
|
|
duplicatePage: (sourcePageId: string, name: string, slug: string) => Promise<TourPage | null>;
|
|
createTransition: (params: TransitionParams) => Promise<void>;
|
|
}
|
|
```
|
|
|
|
`saveConstructor()` serializes `getElements?.() ?? elements` into
|
|
`tour_pages.ui_schema_json.elements`. Passing `getElements` from
|
|
`useConstructorElements` keeps pasted elements from being dropped if Save/Stage
|
|
is clicked immediately after Paste.
|
|
|
|
Page duplication saves the active page first, then calls
|
|
`POST /api/tour_pages/:id/duplicate`; the backend creates an independent dev
|
|
page at the end of the presentation order. Page deletion itself is triggered
|
|
from `constructor.tsx` so the page can show the shared confirmation modal and
|
|
choose the next active page after the API call.
|
|
|
|
---
|
|
|
|
#### useTransitionPreview
|
|
|
|
**File:** `useTransitionPreview.ts` (~200 LOC)
|
|
|
|
**Purpose:** Manages transition video preview state in constructor.
|
|
|
|
```typescript
|
|
interface UseTransitionPreviewResult {
|
|
preview: TransitionPreviewState | null;
|
|
pendingPageId: string;
|
|
openPreview: (element: TransitionElement, direction: 'forward' | 'back') => void;
|
|
openPreviewWithTarget: (element: TransitionElement, direction: 'forward' | 'back', targetPageId: string) => void;
|
|
closePreview: () => void;
|
|
isActive: boolean;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4. Table/List Hooks
|
|
|
|
Hooks for admin data tables.
|
|
|
|
#### useEntityTable
|
|
|
|
**File:** `useEntityTable.ts` (~296 LOC)
|
|
|
|
**Purpose:** Complete state management for entity data tables.
|
|
|
|
```typescript
|
|
interface UseEntityTableReturn<T> {
|
|
data: T[];
|
|
columns: GridColDef[];
|
|
loading: boolean;
|
|
count: number;
|
|
|
|
// Pagination
|
|
currentPage: number;
|
|
setCurrentPage: (page: number) => void;
|
|
numPages: number;
|
|
|
|
// Sorting
|
|
sortModel: GridSortModel;
|
|
setSortModel: (model: GridSortModel) => void;
|
|
|
|
// Selection
|
|
selectedRows: string[];
|
|
setSelectedRows: (ids: string[]) => void;
|
|
|
|
// Filtering
|
|
filterItems: FilterItem[];
|
|
handleFilterSubmit: () => void;
|
|
handleFilterReset: () => void;
|
|
|
|
// Actions
|
|
handleDeleteClick: (id: string) => void;
|
|
handleDeleteConfirm: () => Promise<void>;
|
|
handleRowUpdate: (id: string, data: Partial<T>) => Promise<void>;
|
|
|
|
// Data loading
|
|
loadData: (page?: number, request?: string) => void;
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const tableProps = useEntityTable<User>({
|
|
entityName: 'users',
|
|
entityNamePlural: 'users',
|
|
fetchAction: usersFetch,
|
|
deleteAction: usersDelete,
|
|
configureColumns: configureUsersCols,
|
|
filters: usersFilters,
|
|
});
|
|
|
|
// In component
|
|
<DataGrid
|
|
rows={tableProps.data}
|
|
columns={tableProps.columns}
|
|
loading={tableProps.loading}
|
|
sortModel={tableProps.sortModel}
|
|
onSortModelChange={tableProps.setSortModel}
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
#### useFilterItems
|
|
|
|
**File:** `useFilterItems.ts` (~166 LOC)
|
|
|
|
**Purpose:** Manages filter state for data tables.
|
|
|
|
```typescript
|
|
interface UseFilterItemsReturn {
|
|
filterItems: FilterItem[];
|
|
setFilterItems: (items: FilterItem[]) => void;
|
|
addFilter: () => void;
|
|
removeFilter: (id: string) => void;
|
|
updateFilter: (id: string, fields: Partial<FilterFields>) => void;
|
|
resetFilters: () => void;
|
|
generateFilterRequest: () => string;
|
|
filterRequest: FilterRequest;
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const filters: Filter[] = [
|
|
{ title: 'name', label: 'Name' },
|
|
{ title: 'status', label: 'Status', type: 'enum', options: ['active', 'inactive'] },
|
|
{ title: 'created_at', label: 'Created', date: true },
|
|
];
|
|
|
|
const { filterItems, addFilter, generateFilterRequest } = useFilterItems(filters);
|
|
|
|
// Add filter
|
|
addFilter();
|
|
|
|
// Generate query string
|
|
const queryString = generateFilterRequest(); // "&name=john&statusRange=active"
|
|
```
|
|
|
|
---
|
|
|
|
#### useCSVHandling
|
|
|
|
**File:** `useCSVHandling.ts` (~141 LOC)
|
|
|
|
**Purpose:** CSV upload and download operations.
|
|
|
|
```typescript
|
|
interface UseCSVHandlingReturn {
|
|
csvFile: File | null;
|
|
setCsvFile: (file: File | null) => void;
|
|
isModalActive: boolean;
|
|
setIsModalActive: (active: boolean) => void;
|
|
isUploading: boolean;
|
|
isDownloading: boolean;
|
|
downloadCSV: () => Promise<void>;
|
|
uploadCSV: () => Promise<void>;
|
|
error: string | null;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5. Form Hooks
|
|
|
|
Hooks for form management in edit pages.
|
|
|
|
#### useEditPageSync
|
|
|
|
**File:** `useEditPageSync.ts` (~166 LOC)
|
|
|
|
**Purpose:** Syncs edit page forms with Redux entity state.
|
|
|
|
```typescript
|
|
interface UseEditPageSyncOptions<T> {
|
|
entitySelector: (state: RootState) => unknown;
|
|
fetchAction: AsyncThunk<unknown, { id?: string; query?: string }, object>;
|
|
initialValues: T;
|
|
postProcess?: (entity: T, initial: T) => T;
|
|
idOverride?: string;
|
|
}
|
|
|
|
interface UseEditPageSyncReturn<T> {
|
|
values: T;
|
|
setValues: React.Dispatch<React.SetStateAction<T>>;
|
|
id: string | null;
|
|
isLoading: boolean;
|
|
isFound: boolean;
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const initVals = { name: '', permissions: [] };
|
|
|
|
const { values, id, isLoading } = useEditPageSync({
|
|
entitySelector: (state) => state.roles.roles,
|
|
fetchAction: fetch,
|
|
initialValues: initVals,
|
|
postProcess: (entity, initial) => ({
|
|
...entity,
|
|
permissions: entity.permissions?.map(p => ({ value: p.id, label: p.name })) || [],
|
|
}),
|
|
});
|
|
|
|
// Use in Formik
|
|
<Formik enableReinitialize initialValues={values} onSubmit={handleSubmit}>
|
|
```
|
|
|
|
---
|
|
|
|
#### useFormSync
|
|
|
|
**File:** `useFormSync.ts` (~108 LOC)
|
|
|
|
**Purpose:** Generic form state management with field tracking.
|
|
|
|
```typescript
|
|
interface UseFormSyncReturn<T> {
|
|
values: T;
|
|
setFieldValue: (field: keyof T, value: T[keyof T]) => void;
|
|
setValues: (values: Partial<T>) => void;
|
|
resetForm: () => void;
|
|
isDirty: boolean;
|
|
dirtyFields: Set<keyof T>;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 6. UI Utility Hooks
|
|
|
|
General-purpose utility hooks.
|
|
|
|
#### useDraggable
|
|
|
|
**File:** `useDraggable.ts` (~200 LOC)
|
|
|
|
**Purpose:** Generic draggable panel management with pointer tracking.
|
|
|
|
```typescript
|
|
interface UseDraggableResult {
|
|
position: Position;
|
|
setPosition: (position: Position) => void;
|
|
isDragging: boolean;
|
|
onDragStart: (event: React.MouseEvent) => void;
|
|
onDragStartIgnoreButtons: (event: React.MouseEvent) => void;
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const { position, onDragStart, isDragging } = useDraggable({
|
|
initialPosition: { x: 20, y: 20 },
|
|
elementWidth: 400,
|
|
elementHeight: 300,
|
|
});
|
|
|
|
<div style={{ left: position.x, top: position.y }}>
|
|
<div onMouseDown={onDragStart}>Drag Handle</div>
|
|
<div>Content</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
#### useOutsideClick
|
|
|
|
**File:** `useOutsideClick.ts` (~88 LOC)
|
|
|
|
**Purpose:** Detects clicks outside specified elements.
|
|
|
|
```typescript
|
|
useOutsideClick({
|
|
containerRef: panelRef,
|
|
ignoreRefs: [buttonRef],
|
|
ignoreDataAttribute: 'data-element-id',
|
|
selectedValue: selectedElementId,
|
|
onOutsideClick: () => setSelectedId(''),
|
|
enabled: !!selectedId,
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
#### useElementEffects
|
|
|
|
**File:** `useElementEffects.ts` (~128 LOC)
|
|
|
|
**Purpose:** Manages element interactive effects (hover, focus, active) at runtime. Exposes internal state for integration with other hooks (e.g., `useAudioEffects`).
|
|
|
|
```typescript
|
|
interface UseElementEffectsResult {
|
|
effectStyle: CSSProperties;
|
|
eventHandlers: {
|
|
onMouseEnter: () => void;
|
|
onMouseLeave: () => void;
|
|
onFocus: () => void;
|
|
onBlur: () => void;
|
|
onMouseDown: () => void;
|
|
onMouseUp: () => void;
|
|
onTouchStart: () => void;
|
|
onTouchEnd: () => void;
|
|
};
|
|
onPersistClick: () => void;
|
|
/** Exposed state for audio effects integration */
|
|
state: {
|
|
isHovered: boolean;
|
|
isActive: boolean;
|
|
};
|
|
}
|
|
```
|
|
|
|
**Priority:** active > focus > hover > base
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const { effectStyle, eventHandlers, state: effectState } = useElementEffects(element);
|
|
|
|
// Use effectState for audio integration
|
|
useAudioEffects({
|
|
isHovered: effectState.isHovered,
|
|
isActive: effectState.isActive,
|
|
// ...
|
|
});
|
|
|
|
<div style={{ ...baseStyle, ...effectStyle }} {...eventHandlers}>
|
|
{content}
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
#### useAudioEffects
|
|
|
|
**File:** `useAudioEffects.ts` (~305 LOC)
|
|
|
|
**Purpose:** Manages audio playback for element hover and click effects. Uses HTML5 Audio API for lightweight, non-visual audio playback with authenticated URL support.
|
|
|
|
```typescript
|
|
interface UseAudioEffectsOptions {
|
|
/** URL of audio to play on hover */
|
|
hoverAudioUrl?: string;
|
|
/** URL of audio to play on click/active */
|
|
clickAudioUrl?: string;
|
|
/** Volume level 0-1 (iOS ignores this) */
|
|
volume?: number;
|
|
/** Current hover state from useElementEffects */
|
|
isHovered: boolean;
|
|
/** Current active/pressed state from useElementEffects */
|
|
isActive: boolean;
|
|
/** Optional URL resolver for preloaded blob URLs */
|
|
resolveUrl?: (url: string | undefined) => string;
|
|
/** Reset key to stop audio (e.g., element ID or page slug) */
|
|
resetKey?: string | number;
|
|
}
|
|
|
|
interface UseAudioEffectsResult {
|
|
/** Manually trigger click audio playback */
|
|
playClickAudio: () => void;
|
|
/** Stop all audio playback */
|
|
stopAll: () => void;
|
|
}
|
|
```
|
|
|
|
**Key Features:**
|
|
- **Hover audio**: Plays to completion once started (not interrupted by mouse leave)
|
|
- **Click audio**: Interruptible - clicking again restarts from beginning
|
|
- **Authenticated URLs**: Fetches audio via `fetch()` with credentials, creates blob URLs
|
|
- **Caching**: Tracks fetched URLs to prevent duplicate requests
|
|
- **Mobile support**: Touch events (via `useElementEffects`) trigger hover audio
|
|
|
|
**Cross-Browser Compatibility:**
|
|
|
|
| Feature | Chrome | Firefox | Safari | iOS Safari | Android |
|
|
|---------|--------|---------|--------|------------|---------|
|
|
| HTML5 Audio | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
| Volume control | ✅ | ✅ | ✅ | ❌ (iOS ignores) | ✅ |
|
|
| Autoplay (click) | ✅ | ✅ | ✅ | ✅ (after tap) | ✅ |
|
|
|
|
**Usage:**
|
|
```typescript
|
|
// In CanvasElement.tsx or RuntimeElement.tsx
|
|
const { effectStyle, eventHandlers, state: effectState } = useElementEffects(
|
|
isEditMode ? {} : effectProperties,
|
|
);
|
|
|
|
// Audio effects - only active in preview mode (not edit mode)
|
|
useAudioEffects({
|
|
hoverAudioUrl: isEditMode ? undefined : effectProperties.hoverAudioUrl,
|
|
clickAudioUrl: isEditMode ? undefined : effectProperties.clickAudioUrl,
|
|
volume: parseFloat(effectProperties.audioVolume || '1'),
|
|
isHovered: effectState.isHovered,
|
|
isActive: effectState.isActive,
|
|
resolveUrl, // Resolves to preloaded blob URLs
|
|
resetKey: element.id,
|
|
});
|
|
```
|
|
|
|
**Integration with Element Effects:**
|
|
The hook depends on `useElementEffects` for hover/active state. The `isHovered` and `isActive` states are passed through to trigger audio playback at the right moments.
|
|
|
|
**Constructor vs Runtime:**
|
|
- **Constructor Edit Mode**: Audio disabled (`hoverAudioUrl: undefined`)
|
|
- **Constructor Preview Mode**: Audio enabled (press F5 to test)
|
|
- **Runtime Presentation**: Audio enabled
|
|
|
|
---
|
|
|
|
#### useDashboardCounts
|
|
|
|
**File:** `useDashboardCounts.ts` (~308 LOC)
|
|
|
|
**Purpose:** Fetches entity counts for dashboard with permission filtering.
|
|
|
|
```typescript
|
|
interface UseDashboardCountsReturn {
|
|
counts: Record<string, EntityCountValue>;
|
|
loading: boolean;
|
|
error: Error | null;
|
|
refetch: () => Promise<void>;
|
|
getCount: (key: string) => EntityCountValue;
|
|
getVisibleEntities: () => EntityConfig[];
|
|
}
|
|
```
|
|
|
|
**Dashboard Entities:**
|
|
- users, roles, permissions, projects
|
|
- project_memberships, assets, asset_variants
|
|
- presigned_url_requests, tour_pages, project_audio_tracks
|
|
- publish_events, pwa_caches, access_logs
|
|
|
|
---
|
|
|
|
#### useProjectAssets
|
|
|
|
**File:** `useProjectAssets.ts` (~95 LOC)
|
|
|
|
**Purpose:** Preloads and resolves project assets (favicon, og_image, logo) to presigned URLs. Handles both `storage_key` (relative paths) and legacy `cdn_url` (full S3 URLs) formats.
|
|
|
|
```typescript
|
|
interface ProjectAssetInput {
|
|
favicon_url?: string;
|
|
og_image_url?: string;
|
|
logo_url?: string;
|
|
}
|
|
|
|
interface ProjectAssets {
|
|
faviconUrl: string | null;
|
|
ogImageUrl: string | null;
|
|
logoUrl: string | null;
|
|
isLoading: boolean;
|
|
}
|
|
|
|
function useProjectAssets(project: ProjectAssetInput | null): ProjectAssets;
|
|
```
|
|
|
|
**Key Features:**
|
|
- Extracts storage paths from URLs (handles both formats)
|
|
- Queues presigned URL requests for relative paths
|
|
- Resolves to playback URLs (presigned if available, otherwise proxy)
|
|
- Tracks URL changes to avoid redundant processing
|
|
|
|
**Usage:**
|
|
```typescript
|
|
const { faviconUrl, ogImageUrl, logoUrl, isLoading } = useProjectAssets(project);
|
|
|
|
// Use resolved URLs
|
|
{faviconUrl && <link rel="icon" href={faviconUrl} />}
|
|
{ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
|
{logoUrl && <img src={logoUrl} alt="Logo" />}
|
|
```
|
|
|
|
---
|
|
|
|
#### usePublishStatus
|
|
|
|
**File:** `usePublishStatus.ts` (~113 LOC)
|
|
|
|
**Purpose:** Fetches the last successful publish/save events for a project to display timestamps in the UI. Follows the `useDashboardCounts` pattern with `mountedRef` and parallel API calls.
|
|
|
|
```typescript
|
|
interface UsePublishStatusOptions {
|
|
projectId: string | null;
|
|
}
|
|
|
|
interface UsePublishStatusResult {
|
|
/** Last successful dev → stage save timestamp */
|
|
lastSavedToStage: string | null;
|
|
/** Last successful stage → production publish timestamp */
|
|
lastPublishedToProduction: string | null;
|
|
/** Whether data is loading */
|
|
isLoading: boolean;
|
|
/** Refresh the status (call after new publish) */
|
|
refresh: () => Promise<void>;
|
|
}
|
|
```
|
|
|
|
**Key Features:**
|
|
- Fetches both dev→stage and stage→production events in parallel via `Promise.all`
|
|
- Uses `mountedRef` pattern to prevent state updates after unmount
|
|
- Queries `publish_events` API with `projectId`, environment filters, and `status: 'success'`
|
|
- Returns `finished_at` timestamp of most recent successful event
|
|
|
|
**Usage:**
|
|
```typescript
|
|
// In Project Dashboard (pages/projects/[projectsId].tsx)
|
|
const { lastPublishedToProduction, refresh: refreshPublishStatus } = usePublishStatus({
|
|
projectId,
|
|
});
|
|
|
|
// Display in button subtitle
|
|
<BaseButton
|
|
label="Publish to Production"
|
|
subtitle={lastPublishedToProduction
|
|
? `Last: ${dataFormatter.relativeTimestamp(lastPublishedToProduction)}`
|
|
: undefined}
|
|
onClick={() => setIsPublishModalActive(true)}
|
|
/>
|
|
|
|
// Refresh after successful publish
|
|
const handlePublish = async () => {
|
|
await axios.post('/publish', { projectId, title, description });
|
|
await refreshPublishStatus();
|
|
};
|
|
|
|
// In Constructor (pages/constructor.tsx)
|
|
const { lastSavedToStage, refresh: refreshPublishStatus } = usePublishStatus({
|
|
projectId,
|
|
});
|
|
|
|
// Project-level save timestamp: most recent updatedAt across all pages
|
|
const lastProjectSaveAt = useMemo(() => {
|
|
if (!pages.length) return null;
|
|
return pages.reduce((latest, page) => {
|
|
if (!page.updatedAt) return latest;
|
|
if (!latest) return page.updatedAt;
|
|
return new Date(page.updatedAt) > new Date(latest) ? page.updatedAt : latest;
|
|
}, null as string | null);
|
|
}, [pages]);
|
|
|
|
// Wrap saveToStage to refresh status
|
|
const handleSaveToStage = useCallback(async () => {
|
|
await saveToStage();
|
|
await refreshPublishStatus();
|
|
}, [saveToStage, refreshPublishStatus]);
|
|
|
|
// Pass timestamps to ConstructorMenu
|
|
<ConstructorMenu
|
|
lastSavedAt={lastProjectSaveAt} // Project-level, not page-level
|
|
lastSavedToStageAt={lastSavedToStage}
|
|
onSaveToStage={handleSaveToStage}
|
|
/>
|
|
```
|
|
|
|
**Integration with dataFormatter:**
|
|
```typescript
|
|
import dataFormatter from '../../helpers/dataFormatter';
|
|
|
|
// Format timestamp for display
|
|
dataFormatter.relativeTimestamp(lastPublishedToProduction);
|
|
// → "Just now", "5 min ago", "2 hours ago", "Today at 14:30", "Apr 28 at 16:45"
|
|
```
|
|
|
|
---
|
|
|
|
## Index Exports
|
|
|
|
**File:** `index.ts` (~44 LOC)
|
|
|
|
The index file exports hooks and their types for tree-shaking friendly imports:
|
|
|
|
```typescript
|
|
// Exported from index.ts (centralized)
|
|
export { useFilterItems } from './useFilterItems';
|
|
export { useCSVHandling } from './useCSVHandling';
|
|
export { useFormSync } from './useFormSync';
|
|
export { useEntityTable } from './useEntityTable';
|
|
export { useTransitionPlayback } from './useTransitionPlayback';
|
|
export type { ReverseMode, TransitionConfig, UseTransitionPlaybackOptions,
|
|
PlaybackPhase, UseTransitionPlaybackResult } from './useTransitionPlayback';
|
|
export { usePageNavigation } from './usePageNavigation';
|
|
export type { NavigablePage, UsePageNavigationOptions,
|
|
UsePageNavigationResult } from './usePageNavigation';
|
|
export { usePageNavigationState } from './usePageNavigationState';
|
|
export type { NavigationPhase, UsePageNavigationStateOptions,
|
|
UsePageNavigationStateResult } from './usePageNavigationState';
|
|
export { useBackgroundVideoPlayback } from './useBackgroundVideoPlayback';
|
|
export type { UseBackgroundVideoPlaybackOptions,
|
|
UseBackgroundVideoPlaybackResult } from './useBackgroundVideoPlayback';
|
|
export { useVideoSoundControl } from './useVideoSoundControl';
|
|
export type { UseVideoSoundControlOptions,
|
|
UseVideoSoundControlResult } from './useVideoSoundControl';
|
|
export { usePageDataLoader } from './usePageDataLoader';
|
|
export type { UsePageDataLoaderOptions, UsePageDataLoaderResult } from './usePageDataLoader';
|
|
|
|
// Import directly for better tree-shaking (not in index.ts)
|
|
// - usePreloadOrchestrator
|
|
// - useNeighborGraph
|
|
// - useConstructorElements
|
|
// - useCanvasElementDrag
|
|
// - useTransitionPreview
|
|
// - useOfflineMode
|
|
// - useOutsideClick
|
|
// - useCanvasElapsedTime
|
|
// - useDraggable
|
|
// - useMediaDurationProbe
|
|
// - useIconPreload
|
|
// - useProjectAssets
|
|
```
|
|
|
|
---
|
|
|
|
## Design Patterns
|
|
|
|
### 1. Options/Result Pattern
|
|
|
|
All hooks follow a consistent interface pattern:
|
|
|
|
```typescript
|
|
interface UseXxxOptions {
|
|
// Configuration inputs
|
|
}
|
|
|
|
interface UseXxxResult {
|
|
// State and methods
|
|
}
|
|
|
|
function useXxx(options: UseXxxOptions): UseXxxResult {
|
|
// Implementation
|
|
}
|
|
```
|
|
|
|
### 2. Callback Refs
|
|
|
|
Heavy operations use callback refs to avoid re-renders:
|
|
|
|
```typescript
|
|
const readyBlobUrlsRef = useRef<Map<string, string>>(new Map());
|
|
|
|
// O(1) lookup without triggering re-renders
|
|
const getBlobUrl = useCallback((url: string) => {
|
|
return readyBlobUrlsRef.current.get(url) || null;
|
|
}, []);
|
|
```
|
|
|
|
### 3. Cleanup on Unmount
|
|
|
|
All hooks properly cleanup resources:
|
|
|
|
```typescript
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
|
|
fetchData(controller.signal);
|
|
|
|
return () => controller.abort();
|
|
}, []);
|
|
```
|
|
|
|
### 4. Memoization
|
|
|
|
Expensive computations are memoized:
|
|
|
|
```typescript
|
|
const neighbors = useMemo(() => {
|
|
return buildNeighborGraph(pages, pageLinks);
|
|
}, [pages, pageLinks]);
|
|
```
|
|
|
|
---
|
|
|
|
## Hook Composition
|
|
|
|
Hooks are designed for composition - larger hooks use smaller hooks:
|
|
|
|
```
|
|
RuntimePresentation
|
|
├── usePageDataLoader
|
|
├── usePreloadOrchestrator
|
|
│ └── useNeighborGraph
|
|
├── usePageNavigationState # Unified state machine (replaces 6 hooks)
|
|
│ └── Internal: useReducer + derived state via useMemo
|
|
├── useTransitionPlayback # Uses pre-generated reversed videos
|
|
└── usePageNavigation # History tracking
|
|
```
|
|
|
|
```
|
|
ConstructorPage
|
|
├── usePageDataLoader
|
|
├── usePreloadOrchestrator
|
|
│ └── useNeighborGraph
|
|
├── usePageNavigationState # Same unified state machine
|
|
├── useConstructorElements
|
|
├── useCanvasElementDrag
|
|
├── useConstructorPageActions
|
|
├── useTransitionPreview
|
|
├── useDraggable (multiple instances)
|
|
└── useOutsideClick
|
|
```
|
|
|
|
**Note:** The `usePageNavigationState` hook consolidated:
|
|
- `usePageSwitch` - URL resolution and switching
|
|
- `useBackgroundState` - Background ready tracking
|
|
- `useBackgroundTransition` - Fade-from-black effects
|
|
- `useTransitionCleanup` - Video cleanup coordination
|
|
- `useBackgroundUrls` - URL resolution for display
|
|
- `pageLoadingUtils` - Loading state computation
|
|
|
|
---
|
|
|
|
## Performance Considerations
|
|
|
|
### 1. Tree-Shaking
|
|
|
|
Large hooks are imported directly rather than via index.ts:
|
|
|
|
```typescript
|
|
// Good - only imports what's needed
|
|
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
|
|
|
// Avoid for large hooks
|
|
import { usePreloadOrchestrator } from '../hooks';
|
|
```
|
|
|
|
### 2. Ref-Based State
|
|
|
|
State that doesn't need to trigger re-renders uses refs:
|
|
|
|
```typescript
|
|
// Good - no re-renders
|
|
const queueRef = useRef<PriorityQueue>(new PriorityQueue());
|
|
|
|
// Avoid for high-frequency updates
|
|
const [queue, setQueue] = useState<Item[]>([]);
|
|
```
|
|
|
|
### 3. Debounced Updates
|
|
|
|
High-frequency operations are debounced:
|
|
|
|
```typescript
|
|
const debouncedUpdate = useMemo(
|
|
() => debounce((value) => setPosition(value), 16),
|
|
[]
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Considerations
|
|
|
|
Hooks can be tested with `@testing-library/react-hooks`:
|
|
|
|
```typescript
|
|
import { renderHook, act } from '@testing-library/react-hooks';
|
|
import { useFilterItems } from './useFilterItems';
|
|
|
|
test('adds filter', () => {
|
|
const { result } = renderHook(() => useFilterItems(filters));
|
|
|
|
act(() => {
|
|
result.current.addFilter();
|
|
});
|
|
|
|
expect(result.current.filterItems).toHaveLength(1);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Component-Specific Hooks
|
|
|
|
Some hooks are co-located with their components rather than in the central hooks directory:
|
|
|
|
### useAssetUploader
|
|
|
|
**Location:** `frontend/src/components/Assets/useAssetUploader.ts` (~279 LOC)
|
|
|
|
**Purpose:** Batch asset upload management with queue tracking, concurrent uploads, and MIME validation.
|
|
|
|
```typescript
|
|
interface UseAssetUploaderReturn {
|
|
uploadingSections: string[]; // Sections with active uploads
|
|
uploadQueues: Record<string, UploadQueueItem[]>; // Per-section upload queues
|
|
runBatchUpload: (section: AssetSection, files: File[]) => Promise<void>;
|
|
}
|
|
```
|
|
|
|
**Key Features:**
|
|
- Batch upload with queue management (max 2 concurrent)
|
|
- Per-file progress tracking
|
|
- Abortable uploads via AbortController
|
|
- MIME validation via validation schema
|
|
- Media duration probing for video/audio
|
|
|
|
**Validation Integration:**
|
|
```typescript
|
|
// Passes validation schema to UploadService
|
|
const validationSchema = { assetType: section.assetFormat };
|
|
const remoteFile = await FileUploader.uploadChunked(
|
|
`assets/${projectId}`,
|
|
file,
|
|
validationSchema, // Validates MIME type matches asset format
|
|
{ onProgress, onStatus, signal }
|
|
);
|
|
```
|
|
|
|
See [Asset Upload & Variants](../../documentation/asset-upload-variants.md) for complete upload flow documentation.
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [Video Hooks Module](./video-hooks-module.md) - Video playback primitive hooks
|
|
- [Constructor Page Editor](./constructor-page-editor.md) - Visual tour builder
|
|
- [Runtime Presentation](./runtime-presentation.md) - Tour playback viewer
|
|
- [Navigation & Smooth Transitions](./navigation-smooth-transitions.md) - Page switching with transitions
|
|
- [Frontend Architecture](./frontend-architecture.md) - Overall frontend structure
|
|
- [Components Module](./components-module.md) - React components
|
|
- [Assets Preloading](../../documentation/assets-preloading.md) - Preloading strategy
|
|
- [Asset Upload & Variants](../../documentation/asset-upload-variants.md) - Asset upload pipeline
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Category | Hooks | Total LOC | Notes |
|
|
|----------|-------|-----------|-------|
|
|
| Runtime/Preloading | 10 | ~3,274 | Includes `usePageNavigationState` (~520 LOC) |
|
|
| Video Hooks (new) | 8 | ~1,270 | New composable video primitives |
|
|
| PWA/Offline | 5 | ~1,160 | Unchanged |
|
|
| Constructor | 10 | ~2,156 | Uses shared `usePageNavigationState` |
|
|
| Table/List | 3 | ~595 | Unchanged |
|
|
| Form | 2 | ~274 | Unchanged |
|
|
| UI Utility | 10 | ~1,583 | +useAudioEffects (~305 LOC) |
|
|
| Query Hooks | 13 | ~1,051 | Unchanged |
|
|
| **Total** | **53** | **~10,843** | +8 video hooks, -4 consolidated, +1 audio hook |
|
|
|
|
**Recent Architecture Changes:**
|
|
|
|
1. **Page Navigation Consolidation:** 6+ fragmented hooks consolidated into `usePageNavigationState`:
|
|
- Replaced boolean flag combinations with explicit phases
|
|
- Prevented race conditions via atomic `useReducer` transitions
|
|
- Eliminated ~1,100 LOC of duplicated state management
|
|
|
|
2. **Video Hooks Module:** New `hooks/video/` directory with 8 primitive hooks:
|
|
- Composable primitives for any video playback scenario
|
|
- Used by `useTransitionPlayback`, `useBackgroundVideoPlayback`, `useVideoPlayer`
|
|
- See [Video Hooks Module](./video-hooks-module.md) for details
|
|
|
|
3. **Preloading Simplification:** Stream-first approach:
|
|
- Removed neighbor graph traversal (`useNeighborGraph` deleted)
|
|
- Only preloads current page + outgoing transition videos
|
|
- Videos stream on-demand, then cache for replay
|
|
|
|
4. **Flash Fix:** Instant overlay hide with rAF delay:
|
|
- `TransitionPreviewOverlay` uses `requestAnimationFrame` delay before hiding
|
|
- Ensures new background is painted before overlay removed
|
|
- `PreviousBackgroundOverlay` simplified to pure show/hide (no fade)
|
|
|
|
5. **Audio Effects:** New `useAudioEffects` hook for element sound effects:
|
|
- Plays audio on hover start (completes even after mouse leave)
|
|
- Plays audio on click/active (interruptible, restarts on re-click)
|
|
- Uses authenticated fetch with blob URLs for secure audio access
|
|
- Integrates with `useElementEffects` via exposed `state.isHovered`/`state.isActive`
|
|
- Works in constructor preview mode and runtime presentations
|
|
|
|
The hooks module provides comprehensive reusable logic that powers the application's core features while maintaining clean separation of concerns and tree-shaking friendly architecture.
|