1672 lines
57 KiB
TypeScript
1672 lines
57 KiB
TypeScript
import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js';
|
|
import axios from 'axios';
|
|
import Head from 'next/head';
|
|
import { useRouter } from 'next/router';
|
|
import React, {
|
|
ReactElement,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import BaseButton from '../components/BaseButton';
|
|
import CanvasBackground from '../components/Constructor/CanvasBackground';
|
|
import ConstructorControlsPanel from '../components/Constructor/ConstructorControlsPanel';
|
|
import ConstructorMenu from '../components/Constructor/ConstructorMenu';
|
|
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
|
|
import CanvasElementComponent from '../components/Constructor/CanvasElement';
|
|
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
|
|
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
|
import { BackdropPortalProvider } from '../components/BackdropPortal';
|
|
import { getPageTitle } from '../config';
|
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
|
import { usePageSwitch } from '../hooks/usePageSwitch';
|
|
import { usePageNavigation } from '../hooks/usePageNavigation';
|
|
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
|
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
|
import { logger } from '../lib/logger';
|
|
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
|
import { parseJsonObject } from '../lib/parseJson';
|
|
import {
|
|
resolveNavigationTarget,
|
|
hasPlayableTransition,
|
|
getNavigationDirection,
|
|
isBackNavigation,
|
|
} from '../lib/navigationHelpers';
|
|
import {
|
|
mergeElementWithDefaults,
|
|
createLocalId,
|
|
normalizeAppearDelaySec,
|
|
normalizeAppearDurationSec,
|
|
ELEMENT_TYPE_LABELS,
|
|
getNavigationButtonKind,
|
|
isNavigationElementType,
|
|
isTooltipElementType,
|
|
isDescriptionElementType,
|
|
isMediaElementType,
|
|
isVideoPlayerElementType,
|
|
clamp,
|
|
} from '../lib/elementDefaults';
|
|
import type {
|
|
CanvasElementType,
|
|
CanvasElement,
|
|
ConstructorSchema,
|
|
ConstructorAsset as ProjectAsset,
|
|
EditorMenuItem,
|
|
GalleryCard,
|
|
GalleryInfoSpan,
|
|
CarouselSlide,
|
|
} from '../types/constructor';
|
|
import type { TourPage } from '../types/entities';
|
|
|
|
// Constructor-specific hooks
|
|
import {
|
|
useCanvasElapsedTime,
|
|
isElementVisibleAtTime,
|
|
} from '../hooks/useCanvasElapsedTime';
|
|
import {
|
|
useMediaDurationProbe,
|
|
buildDurationProbeTargets,
|
|
} from '../hooks/useMediaDurationProbe';
|
|
import { useIconPreload } from '../hooks/useIconPreload';
|
|
import { useOutsideClick } from '../hooks/useOutsideClick';
|
|
import { useDraggable } from '../hooks/useDraggable';
|
|
import { useCanvasElementDrag } from '../hooks/useCanvasElementDrag';
|
|
import { useTransitionPreview } from '../hooks/useTransitionPreview';
|
|
import { useConstructorPageActions } from '../hooks/useConstructorPageActions';
|
|
import { useConstructorElements } from '../hooks/useConstructorElements';
|
|
import { usePageBackground } from '../hooks/usePageBackground';
|
|
import { useConstructorData } from '../hooks/useConstructorData';
|
|
import { useAssetOptions } from '../hooks/useAssetOptions';
|
|
import { useTransitionCreation } from '../hooks/useTransitionCreation';
|
|
import {
|
|
ConstructorProvider,
|
|
type ConstructorContextValue,
|
|
type NavigationElementType,
|
|
} from '../context/ConstructorContext';
|
|
import { useCanvasScale } from '../hooks/useCanvasScale';
|
|
import { CANVAS_CONFIG } from '../config/canvas.config';
|
|
|
|
// Constructor helpers (extracted utilities)
|
|
import {
|
|
getAssetLabel,
|
|
getAssetSourceValue,
|
|
isBackgroundImageAsset,
|
|
} from '../lib/constructorHelpers';
|
|
|
|
// TourPage type is imported from '../types/entities'
|
|
// NavigationElementType is imported from '../context/ConstructorContext'
|
|
|
|
type ConstructorPageProps = {
|
|
mode?: 'constructor' | 'element_edit';
|
|
};
|
|
|
|
type ConstructorInteractionMode = 'edit' | 'interact';
|
|
|
|
// Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup
|
|
const labelByType = ELEMENT_TYPE_LABELS;
|
|
|
|
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|
const router = useRouter();
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
const elementEditorRef = useRef<HTMLDivElement>(null);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const [isAuthReady, setIsAuthReady] = useState(false);
|
|
const isElementEditMode = mode === 'element_edit';
|
|
|
|
const projectId = useMemo(() => {
|
|
const value = router.query.projectId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.projectId]);
|
|
const pageElementsListHref = useMemo(() => {
|
|
if (!projectId)
|
|
return '/project-element-defaults/project-element-defaults-list';
|
|
return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`;
|
|
}, [projectId]);
|
|
|
|
const pageIdFromRoute = useMemo(() => {
|
|
const value = router.query.pageId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.pageId]);
|
|
const elementIdFromRoute = useMemo(() => {
|
|
const value = router.query.elementId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.elementId]);
|
|
|
|
// Use React Query for data fetching (replaces manual loadData)
|
|
const {
|
|
project,
|
|
pages,
|
|
pageLinks,
|
|
allPagesPreloadElements,
|
|
assets,
|
|
uiElementDefaultsByType,
|
|
projectName,
|
|
isLoading: isDataLoading,
|
|
isError: isDataError,
|
|
error: dataError,
|
|
refetch: refetchData,
|
|
} = useConstructorData({
|
|
projectId,
|
|
isAuthReady,
|
|
});
|
|
|
|
// Canvas scale for responsive UI elements and letterbox mode
|
|
const { cssVars: canvasCssVars, letterboxStyles } = useCanvasScale({
|
|
designWidth: project?.design_width,
|
|
designHeight: project?.design_height,
|
|
});
|
|
|
|
// Page navigation with history tracking via shared hook
|
|
const {
|
|
currentPageId: activePageId,
|
|
pageHistory,
|
|
applyPageSelection,
|
|
getNavigationContext,
|
|
setCurrentPageId: setActivePageId,
|
|
} = usePageNavigation({
|
|
pages,
|
|
trackHistory: true,
|
|
});
|
|
|
|
// Consolidated page background state (replaces 8 separate useState hooks)
|
|
const {
|
|
background: pageBackground,
|
|
setBackground: setPageBackground,
|
|
updateFromPage: updateBackgroundFromPage,
|
|
setImageUrl: setBackgroundImageUrl,
|
|
setVideoUrl: setBackgroundVideoUrl,
|
|
setAudioUrl: setBackgroundAudioUrl,
|
|
setVideoSettings: setBackgroundVideoSettings,
|
|
// Legacy compatibility values for components that expect flat props
|
|
backgroundImageUrl,
|
|
backgroundVideoUrl,
|
|
backgroundAudioUrl,
|
|
backgroundVideoAutoplay,
|
|
backgroundVideoLoop,
|
|
backgroundVideoMuted,
|
|
backgroundVideoStartTime,
|
|
backgroundVideoEndTime,
|
|
} = usePageBackground();
|
|
|
|
const [selectedMenuItem, setSelectedMenuItem] =
|
|
useState<EditorMenuItem>('none');
|
|
// Transition preview state managed by useTransitionPreview hook (below)
|
|
|
|
// Combined loading state: initial auth check + data loading
|
|
const [isInitializing, setIsInitializing] = useState(true);
|
|
const isLoading = isInitializing || isDataLoading;
|
|
// isSaving, isSavingToStage, isCreatingPage are managed by useConstructorPageActions hook
|
|
const [newTransitionName, setNewTransitionName] = useState('');
|
|
const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState('');
|
|
const [newTransitionSupportsReverse, setNewTransitionSupportsReverse] =
|
|
useState(true);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [successMessage, setSuccessMessage] = useState('');
|
|
const [constructorInteractionMode, setConstructorInteractionMode] =
|
|
useState<ConstructorInteractionMode>('edit');
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
const [isEditorCollapsed, setIsEditorCollapsed] = useState(false);
|
|
const [elementEditorTab, setElementEditorTab] = useState<
|
|
'general' | 'css' | 'effects'
|
|
>('general');
|
|
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
|
|
element: CanvasElement;
|
|
initialIndex: number;
|
|
} | null>(null);
|
|
// Track background ready state for smooth video transition completion
|
|
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
|
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
|
useState(false);
|
|
|
|
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
|
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
|
return ['navigation_next', 'navigation_prev'];
|
|
}, []);
|
|
|
|
// Element CRUD operations via useConstructorElements hook
|
|
const {
|
|
elements,
|
|
setElements,
|
|
selectedElementId,
|
|
selectedElement,
|
|
selectElement,
|
|
clearSelection,
|
|
addElement,
|
|
updateSelectedElement,
|
|
removeSelectedElement,
|
|
galleryCards,
|
|
galleryInfoSpans,
|
|
carouselSlides,
|
|
updateElementPosition,
|
|
normalizeNavigationType,
|
|
} = useConstructorElements({
|
|
initialElements: [],
|
|
elementDefaultsByType: uiElementDefaultsByType,
|
|
allowedNavigationTypes,
|
|
initialSelectedElementId: elementIdFromRoute,
|
|
onElementSelected: useCallback(() => {
|
|
setSelectedMenuItem('none');
|
|
}, []),
|
|
onSelectionCleared: useCallback(() => {
|
|
setSelectedMenuItem('none');
|
|
}, []),
|
|
onElementAdded: useCallback(() => {
|
|
setSuccessMessage('Element added. Drag it to set position.');
|
|
setErrorMessage('');
|
|
}, []),
|
|
onElementRemoved: useCallback(() => {
|
|
setSuccessMessage('Element removed.');
|
|
}, []),
|
|
});
|
|
|
|
// Check if any element has full-width carousel mode enabled
|
|
const hasFullWidthCarousel = useMemo(
|
|
() => elements.some((el) => el.carouselFullWidth === true),
|
|
[elements],
|
|
);
|
|
|
|
// Draggable panels using useDraggable hook
|
|
const {
|
|
position: constructorControlsPosition,
|
|
onDragStart: onConstructorControlsDragStart,
|
|
} = useDraggable({
|
|
initialPosition: { x: 20, y: 20 },
|
|
elementWidth: 460,
|
|
elementHeight: 64,
|
|
});
|
|
|
|
const { position: menuPosition, onDragStart: onMenuDragStart } = useDraggable(
|
|
{
|
|
initialPosition: { x: 9999, y: 10 }, // Top right corner (x will be clamped)
|
|
elementWidth: 240,
|
|
elementHeight: 60,
|
|
},
|
|
);
|
|
|
|
const { position: editorPosition, onDragStart: onElementEditorDragStart } =
|
|
useDraggable({
|
|
initialPosition: { x: 0, y: 72 },
|
|
elementWidth: isEditorCollapsed ? 260 : 380,
|
|
elementHeight: 60,
|
|
});
|
|
|
|
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
const lastInitializedPageIdRef = useRef<string | null>(null);
|
|
const didSetInitialCanvasFocus = useRef(false);
|
|
const selectedElementIdRef = useRef<string>('');
|
|
selectedElementIdRef.current = selectedElementId;
|
|
|
|
const activePage = useMemo(
|
|
() => pages.find((item) => item.id === activePageId) || null,
|
|
[activePageId, pages],
|
|
);
|
|
|
|
// Transition preview state management
|
|
const {
|
|
preview: transitionPreview,
|
|
pendingPageId: pendingNavigationPageId,
|
|
openPreview: openTransitionPreviewForElement,
|
|
openPreviewWithTarget,
|
|
closePreview: closeTransitionPreview,
|
|
} = useTransitionPreview({
|
|
isNavigationElementType,
|
|
onError: setErrorMessage,
|
|
});
|
|
|
|
// Canvas elapsed time for element visibility timing
|
|
const { elapsedSec: canvasElapsedSec } = useCanvasElapsedTime({
|
|
pageId: activePageId,
|
|
enabled: !isLoading,
|
|
});
|
|
|
|
// Element dragging with percentage positioning
|
|
const { onElementDragStart, cancelDrag: cancelElementDrag } =
|
|
useCanvasElementDrag({
|
|
canvasRef,
|
|
onPositionChange: updateElementPosition,
|
|
enabled: constructorInteractionMode === 'edit',
|
|
});
|
|
|
|
// Preload orchestrator for better DX when previewing pages
|
|
// Preloads neighbor page assets and transition videos
|
|
// Uses allPagesPreloadElements (extracted in loadData) for proper neighbor preloading
|
|
const preloadOrchestrator = usePreloadOrchestrator({
|
|
pages: pages.map((p) => ({
|
|
id: p.id,
|
|
background_image_url: p.background_image_url,
|
|
background_video_url: p.background_video_url,
|
|
background_audio_url: p.background_audio_url,
|
|
})),
|
|
pageLinks,
|
|
elements: allPagesPreloadElements, // Use elements from ALL pages for proper neighbor preloading
|
|
currentPageId: activePageId,
|
|
enabled: !isLoading && !!activePageId,
|
|
// maxNeighborDepth defaults to 1 - only preload immediate neighbors
|
|
});
|
|
|
|
// Page switch hook for smooth background transitions (uses blob URLs from preload cache)
|
|
const pageSwitch = usePageSwitch({
|
|
preloadCache: preloadOrchestrator
|
|
? {
|
|
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
}
|
|
: undefined,
|
|
});
|
|
// Destructure stable callback reference to avoid infinite loops in useEffect deps
|
|
const pageSwitchToPage = pageSwitch.switchToPage;
|
|
|
|
// Use shared background transition hook for direct navigation clearing and crossfade
|
|
// Crossfade starts automatically when new background is ready
|
|
const { isFadingIn } = useBackgroundTransition({
|
|
pageSwitch,
|
|
fadeIn: {
|
|
hasActiveTransition: Boolean(transitionPreview),
|
|
},
|
|
});
|
|
|
|
// Video transition overlay removal - instant (no fade) when background is ready
|
|
// This ensures UI elements have time to appear before we remove the transition overlay
|
|
useEffect(() => {
|
|
if (pendingTransitionComplete && isBackgroundReady) {
|
|
// Background is ready - instantly remove transition overlay (no fade)
|
|
const video = transitionVideoRef.current;
|
|
if (video) {
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
}
|
|
closeTransitionPreview();
|
|
setPendingTransitionComplete(false);
|
|
}
|
|
}, [pendingTransitionComplete, isBackgroundReady, closeTransitionPreview]);
|
|
|
|
// Handle background ready state for pages without images or with videos
|
|
useEffect(() => {
|
|
// If no background image, or if there's a video (video takes over), mark as ready
|
|
if (!activePage?.background_image_url || activePage?.background_video_url) {
|
|
setIsBackgroundReady(true);
|
|
}
|
|
}, [activePage?.background_image_url, activePage?.background_video_url]);
|
|
|
|
// Reset pending state when starting a new transition
|
|
useEffect(() => {
|
|
if (transitionPreview) {
|
|
setPendingTransitionComplete(false);
|
|
}
|
|
}, [transitionPreview]);
|
|
|
|
// Helper to switch pages without flash
|
|
// Uses usePageSwitch hook to resolve blob URLs from preload cache
|
|
// Also updates storage path state for editing/saving purposes
|
|
// isBack parameter indicates this is a back navigation (pops history instead of pushing)
|
|
const switchToPage = useCallback(
|
|
async (page: TourPage | null, isBack = false) => {
|
|
// Mark this page as initialized to prevent redundant effect calls
|
|
if (page) {
|
|
lastInitializedPageIdRef.current = page.id;
|
|
}
|
|
|
|
// Update consolidated background state (replaces 8 separate setters)
|
|
updateBackgroundFromPage(page);
|
|
|
|
// Use hook to resolve and set blob URLs for display
|
|
// Fade starts automatically when new background is ready (crossfade effect)
|
|
await pageSwitchToPage(
|
|
page
|
|
? {
|
|
id: page.id,
|
|
background_image_url: page.background_image_url,
|
|
background_video_url: page.background_video_url,
|
|
background_audio_url: page.background_audio_url,
|
|
}
|
|
: null,
|
|
() => {
|
|
if (page) {
|
|
// Use applyPageSelection for proper history management (pops on back)
|
|
applyPageSelection(page.id, isBack);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
[pageSwitchToPage, updateBackgroundFromPage, applyPageSelection],
|
|
);
|
|
|
|
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
|
|
videoRef: transitionVideoRef,
|
|
transition: transitionPreview
|
|
? {
|
|
videoUrl: resolveAssetPlaybackUrl(transitionPreview.videoUrl),
|
|
storageKey: transitionPreview.storageKey,
|
|
reverseMode: transitionPreview.reverseMode,
|
|
reverseVideoUrl: transitionPreview.reverseVideoUrl
|
|
? resolveAssetPlaybackUrl(transitionPreview.reverseVideoUrl)
|
|
: undefined,
|
|
durationSec: transitionPreview.durationSec,
|
|
targetPageId: pendingNavigationPageId || undefined,
|
|
displayName: transitionPreview.title,
|
|
isBack: transitionPreview.isBack, // Pass through for history management
|
|
}
|
|
: null,
|
|
onComplete: async (targetPageId, isBack) => {
|
|
const video = transitionVideoRef.current;
|
|
if (targetPageId) {
|
|
const targetPage = pages.find((p) => p.id === targetPageId) || null;
|
|
// Use switchToPage which resolves blob URLs via usePageSwitch
|
|
// Pass isBack flag for proper history management (pops on back)
|
|
await switchToPage(targetPage, isBack ?? false);
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
setErrorMessage('');
|
|
setIsBackgroundReady(false);
|
|
// Signal that transition video completed - wait for background to load
|
|
// Overlay will be removed instantly when isBackgroundReady becomes true
|
|
setPendingTransitionComplete(true);
|
|
} else {
|
|
video?.removeAttribute('src');
|
|
video?.load();
|
|
closeTransitionPreview();
|
|
setPendingTransitionComplete(false);
|
|
}
|
|
},
|
|
timeouts: {
|
|
playbackStartMs: 3000,
|
|
hardTimeoutMs: 45000,
|
|
},
|
|
features: {
|
|
useBlobUrl: true,
|
|
preDecodeImages: false, // We handle image loading via usePageSwitch
|
|
},
|
|
preload: {
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
|
|
},
|
|
});
|
|
|
|
const iconPreloadTargets = useMemo(() => {
|
|
const preloadableTypes: CanvasElementType[] = [
|
|
'navigation_next',
|
|
'navigation_prev',
|
|
'tooltip',
|
|
'description',
|
|
];
|
|
const urls = elements
|
|
.filter(
|
|
(element) =>
|
|
preloadableTypes.includes(element.type) && Boolean(element.iconUrl),
|
|
)
|
|
.map((element) => resolveAssetPlaybackUrl(element.iconUrl))
|
|
.filter(Boolean);
|
|
|
|
return Array.from(new Set(urls));
|
|
}, [elements]);
|
|
|
|
// Icon preloading for smooth rendering
|
|
const { preloadedUrlMap: preloadedIconUrlMap } = useIconPreload({
|
|
iconUrls: iconPreloadTargets,
|
|
enabled: !isLoading,
|
|
});
|
|
|
|
// Use the useAssetOptions hook to derive memoized asset options
|
|
const assetOptions = useAssetOptions({ assets });
|
|
// Media duration probing with caching
|
|
const durationProbeTargets = useMemo(
|
|
() =>
|
|
buildDurationProbeTargets({
|
|
backgroundVideoUrl,
|
|
backgroundAudioUrl,
|
|
selectedElement,
|
|
newTransitionVideoUrl,
|
|
elements,
|
|
isMediaElementType,
|
|
isVideoPlayerElementType,
|
|
isNavigationElementType,
|
|
}),
|
|
[
|
|
backgroundAudioUrl,
|
|
backgroundVideoUrl,
|
|
elements,
|
|
newTransitionVideoUrl,
|
|
selectedElement,
|
|
],
|
|
);
|
|
|
|
const { getDuration, getDurationNote, durationBySource } =
|
|
useMediaDurationProbe({
|
|
targets: durationProbeTargets,
|
|
});
|
|
|
|
const backgroundVideoDurationNote = getDurationNote(backgroundVideoUrl);
|
|
const backgroundAudioDurationNote = getDurationNote(backgroundAudioUrl);
|
|
const selectedMediaDurationNote = useMemo(() => {
|
|
if (!selectedElement || !isMediaElementType(selectedElement.type)) {
|
|
return 'Duration: unknown';
|
|
}
|
|
return getDurationNote(selectedElement.mediaUrl || '');
|
|
}, [getDurationNote, selectedElement]);
|
|
|
|
const newTransitionDurationNote = getDurationNote(newTransitionVideoUrl);
|
|
|
|
const selectedTransitionDurationNote = useMemo(() => {
|
|
if (!selectedElement || !isNavigationElementType(selectedElement.type)) {
|
|
return 'Duration: unknown';
|
|
}
|
|
return getDurationNote(selectedElement.transitionVideoUrl || '');
|
|
}, [getDurationNote, selectedElement]);
|
|
|
|
useEffect(() => {
|
|
if (newTransitionVideoUrl) return;
|
|
if (!assetOptions.transitionVideo.length) return;
|
|
setNewTransitionVideoUrl(assetOptions.transitionVideo[0].value);
|
|
}, [newTransitionVideoUrl, assetOptions.transitionVideo]);
|
|
|
|
useEffect(() => {
|
|
setElements((prev) => {
|
|
let hasChanges = false;
|
|
const next = prev.map((element) => {
|
|
if (!isNavigationElementType(element.type)) return element;
|
|
|
|
const resolvedDuration = getDuration(element.transitionVideoUrl || '');
|
|
const nextDuration =
|
|
Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0
|
|
? Number(resolvedDuration)
|
|
: undefined;
|
|
if (element.transitionDurationSec === nextDuration) return element;
|
|
|
|
hasChanges = true;
|
|
return {
|
|
...element,
|
|
transitionDurationSec: nextDuration,
|
|
};
|
|
});
|
|
|
|
return hasChanges ? next : prev;
|
|
});
|
|
}, [getDuration]);
|
|
|
|
// Handle initial page selection when pages are loaded
|
|
const prevPagesLengthRef = useRef(0);
|
|
useEffect(() => {
|
|
// Only set initial page when pages first load (not on every change)
|
|
if (pages.length > 0 && prevPagesLengthRef.current === 0) {
|
|
const defaultPageId = pageIdFromRoute || pages[0]?.id || '';
|
|
// Use applyPageSelection to set initial page and history
|
|
applyPageSelection(defaultPageId, false);
|
|
setIsMenuOpen(false);
|
|
setIsInitializing(false);
|
|
}
|
|
prevPagesLengthRef.current = pages.length;
|
|
}, [pages, pageIdFromRoute, applyPageSelection]);
|
|
|
|
// Handle query errors
|
|
useEffect(() => {
|
|
if (isDataError && dataError) {
|
|
const message = dataError.message || 'Failed to load constructor data.';
|
|
logger.error('Failed to load constructor data:', dataError);
|
|
setErrorMessage(message);
|
|
}
|
|
}, [isDataError, dataError]);
|
|
|
|
// Refetch wrapper that preserves current page
|
|
const handleReload = useCallback(async () => {
|
|
const currentPageId = activePageId;
|
|
await refetchData();
|
|
// After refetch, restore the active page if it still exists
|
|
// This is handled by the pages effect above
|
|
if (currentPageId && pages.some((p) => p.id === currentPageId)) {
|
|
setActivePageId(currentPageId);
|
|
}
|
|
}, [activePageId, pages, refetchData]);
|
|
|
|
// Page actions (save, create page, save to stage)
|
|
const {
|
|
isSaving,
|
|
isSavingToStage,
|
|
isCreatingPage,
|
|
isCreatingTransition,
|
|
saveConstructor,
|
|
saveToStage,
|
|
createPage,
|
|
createTransition,
|
|
} = useConstructorPageActions({
|
|
projectId,
|
|
project,
|
|
pages,
|
|
activePage,
|
|
activePageId,
|
|
elements,
|
|
pageBackground,
|
|
onReload: handleReload,
|
|
onSetActivePageId: setActivePageId,
|
|
onSetMenuOpen: setIsMenuOpen,
|
|
onError: setErrorMessage,
|
|
onSuccess: setSuccessMessage,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || typeof window === 'undefined') return;
|
|
|
|
const token =
|
|
sessionStorage.getItem('token') || localStorage.getItem('token');
|
|
if (!token) {
|
|
setIsAuthReady(false);
|
|
setErrorMessage('Please sign in to continue.');
|
|
router.replace('/login');
|
|
return;
|
|
}
|
|
|
|
setIsAuthReady(true);
|
|
}, [router]);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady) return;
|
|
if (projectId) return;
|
|
router.replace(
|
|
isElementEditMode
|
|
? '/project-element-defaults/project-element-defaults-list'
|
|
: '/projects/projects-list',
|
|
);
|
|
}, [isElementEditMode, projectId, router]);
|
|
|
|
// React Query handles data fetching automatically based on projectId and isAuthReady
|
|
|
|
// Panel initial positions are handled by useDraggable hooks
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || !isAuthReady || isLoading) return;
|
|
if (didSetInitialCanvasFocus.current) return;
|
|
if (!canvasRef.current) return;
|
|
|
|
didSetInitialCanvasFocus.current = true;
|
|
requestAnimationFrame(() => {
|
|
canvasRef.current?.focus({ preventScroll: true });
|
|
});
|
|
}, [isAuthReady, isLoading, router.isReady]);
|
|
|
|
useEffect(() => {
|
|
if (!activePage) {
|
|
setElements([]);
|
|
clearSelection();
|
|
updateBackgroundFromPage(null);
|
|
return;
|
|
}
|
|
|
|
const schema = parseJsonObject<ConstructorSchema>(
|
|
activePage.ui_schema_json,
|
|
{},
|
|
);
|
|
const normalizedElements = Array.isArray(schema.elements)
|
|
? schema.elements
|
|
.filter(
|
|
(item) =>
|
|
item && item.type && labelByType[item.type as CanvasElementType],
|
|
)
|
|
.map((item) => {
|
|
const elementType = item.type as CanvasElementType;
|
|
const normalizedElement: CanvasElement = {
|
|
...item,
|
|
id: String(item.id || createLocalId()),
|
|
label:
|
|
typeof item.label === 'string' && item.label.trim()
|
|
? item.label
|
|
: labelByType[elementType],
|
|
xPercent: clamp(Number(item.xPercent || 0), 0, 100),
|
|
yPercent: clamp(Number(item.yPercent || 0), 0, 100),
|
|
appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec),
|
|
appearDurationSec: normalizeAppearDurationSec(
|
|
item.appearDurationSec,
|
|
),
|
|
galleryCards: Array.isArray(item.galleryCards)
|
|
? item.galleryCards.map((card: Partial<GalleryCard>) => ({
|
|
id: String(card?.id || createLocalId()),
|
|
imageUrl: String(card?.imageUrl ?? ''),
|
|
title: String(card?.title ?? ''),
|
|
description: String(card?.description ?? ''),
|
|
}))
|
|
: undefined,
|
|
galleryHeaderImageUrl:
|
|
typeof item.galleryHeaderImageUrl === 'string'
|
|
? item.galleryHeaderImageUrl
|
|
: undefined,
|
|
galleryTitle:
|
|
typeof item.galleryTitle === 'string'
|
|
? item.galleryTitle
|
|
: undefined,
|
|
galleryInfoSpans: Array.isArray(item.galleryInfoSpans)
|
|
? item.galleryInfoSpans.map(
|
|
(span: Partial<GalleryInfoSpan>) => ({
|
|
id: String(span?.id || createLocalId()),
|
|
text: String(span?.text ?? ''),
|
|
iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined,
|
|
}),
|
|
)
|
|
: undefined,
|
|
galleryColumns:
|
|
typeof item.galleryColumns === 'number'
|
|
? item.galleryColumns
|
|
: undefined,
|
|
carouselSlides: Array.isArray(item.carouselSlides)
|
|
? item.carouselSlides.map((slide: Partial<CarouselSlide>) => ({
|
|
id: String(slide?.id || createLocalId()),
|
|
imageUrl: String(slide?.imageUrl ?? ''),
|
|
caption: String(slide?.caption ?? ''),
|
|
}))
|
|
: undefined,
|
|
iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '',
|
|
carouselPrevIconUrl:
|
|
typeof item.carouselPrevIconUrl === 'string'
|
|
? item.carouselPrevIconUrl
|
|
: '',
|
|
carouselNextIconUrl:
|
|
typeof item.carouselNextIconUrl === 'string'
|
|
? item.carouselNextIconUrl
|
|
: '',
|
|
// Carousel button positions
|
|
carouselPrevX:
|
|
typeof item.carouselPrevX === 'number'
|
|
? item.carouselPrevX
|
|
: undefined,
|
|
carouselPrevY:
|
|
typeof item.carouselPrevY === 'number'
|
|
? item.carouselPrevY
|
|
: undefined,
|
|
carouselNextX:
|
|
typeof item.carouselNextX === 'number'
|
|
? item.carouselNextX
|
|
: undefined,
|
|
carouselNextY:
|
|
typeof item.carouselNextY === 'number'
|
|
? item.carouselNextY
|
|
: undefined,
|
|
// Carousel button dimensions
|
|
carouselPrevWidth:
|
|
typeof item.carouselPrevWidth === 'string'
|
|
? item.carouselPrevWidth
|
|
: undefined,
|
|
carouselPrevHeight:
|
|
typeof item.carouselPrevHeight === 'string'
|
|
? item.carouselPrevHeight
|
|
: undefined,
|
|
carouselNextWidth:
|
|
typeof item.carouselNextWidth === 'string'
|
|
? item.carouselNextWidth
|
|
: undefined,
|
|
carouselNextHeight:
|
|
typeof item.carouselNextHeight === 'string'
|
|
? item.carouselNextHeight
|
|
: undefined,
|
|
// Gallery Carousel Settings
|
|
galleryCarouselPrevIconUrl:
|
|
typeof item.galleryCarouselPrevIconUrl === 'string'
|
|
? item.galleryCarouselPrevIconUrl
|
|
: '',
|
|
galleryCarouselNextIconUrl:
|
|
typeof item.galleryCarouselNextIconUrl === 'string'
|
|
? item.galleryCarouselNextIconUrl
|
|
: '',
|
|
galleryCarouselBackIconUrl:
|
|
typeof item.galleryCarouselBackIconUrl === 'string'
|
|
? item.galleryCarouselBackIconUrl
|
|
: '',
|
|
galleryCarouselBackLabel:
|
|
typeof item.galleryCarouselBackLabel === 'string'
|
|
? item.galleryCarouselBackLabel
|
|
: '',
|
|
galleryCarouselPrevX:
|
|
typeof item.galleryCarouselPrevX === 'number'
|
|
? item.galleryCarouselPrevX
|
|
: undefined,
|
|
galleryCarouselPrevY:
|
|
typeof item.galleryCarouselPrevY === 'number'
|
|
? item.galleryCarouselPrevY
|
|
: undefined,
|
|
galleryCarouselNextX:
|
|
typeof item.galleryCarouselNextX === 'number'
|
|
? item.galleryCarouselNextX
|
|
: undefined,
|
|
galleryCarouselNextY:
|
|
typeof item.galleryCarouselNextY === 'number'
|
|
? item.galleryCarouselNextY
|
|
: undefined,
|
|
galleryCarouselBackX:
|
|
typeof item.galleryCarouselBackX === 'number'
|
|
? item.galleryCarouselBackX
|
|
: undefined,
|
|
galleryCarouselBackY:
|
|
typeof item.galleryCarouselBackY === 'number'
|
|
? item.galleryCarouselBackY
|
|
: undefined,
|
|
tooltipTitle:
|
|
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
|
|
tooltipText:
|
|
typeof item.tooltipText === 'string' ? item.tooltipText : '',
|
|
descriptionTitle:
|
|
typeof item.descriptionTitle === 'string'
|
|
? item.descriptionTitle
|
|
: '',
|
|
descriptionText:
|
|
typeof item.descriptionText === 'string'
|
|
? item.descriptionText
|
|
: '',
|
|
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
|
|
navType:
|
|
item.navType === 'back' || item.navType === 'forward'
|
|
? item.navType
|
|
: isNavigationElementType(elementType)
|
|
? getNavigationButtonKind(
|
|
elementType as NavigationElementType,
|
|
)
|
|
: undefined,
|
|
// Support both targetPageSlug (new) and targetPageId (legacy)
|
|
targetPageSlug:
|
|
typeof item.targetPageSlug === 'string'
|
|
? item.targetPageSlug
|
|
: '',
|
|
targetPageId:
|
|
typeof item.targetPageId === 'string' ? item.targetPageId : '',
|
|
transitionVideoUrl:
|
|
typeof item.transitionVideoUrl === 'string'
|
|
? item.transitionVideoUrl
|
|
: '',
|
|
transitionReverseMode:
|
|
item.transitionReverseMode === 'separate_video'
|
|
? 'separate_video'
|
|
: ('auto_reverse' as const),
|
|
reverseVideoUrl:
|
|
typeof item.reverseVideoUrl === 'string'
|
|
? item.reverseVideoUrl
|
|
: '',
|
|
transitionDurationSec: item.transitionDurationSec
|
|
? Number(item.transitionDurationSec)
|
|
: undefined,
|
|
mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '',
|
|
mediaAutoplay:
|
|
typeof item.mediaAutoplay === 'boolean'
|
|
? item.mediaAutoplay
|
|
: true,
|
|
mediaLoop:
|
|
typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true,
|
|
mediaMuted:
|
|
typeof item.mediaMuted === 'boolean'
|
|
? item.mediaMuted
|
|
: isVideoPlayerElementType(item.type),
|
|
};
|
|
|
|
return mergeElementWithDefaults(
|
|
normalizedElement,
|
|
uiElementDefaultsByType[elementType],
|
|
{ preferElementValues: true },
|
|
);
|
|
})
|
|
: [];
|
|
|
|
setElements(normalizedElements);
|
|
setSelectedMenuItem('none');
|
|
|
|
const currentSelectedId = selectedElementIdRef.current;
|
|
if (!normalizedElements.length) {
|
|
clearSelection();
|
|
} else if (
|
|
elementIdFromRoute &&
|
|
normalizedElements.some((element) => element.id === elementIdFromRoute)
|
|
) {
|
|
selectElement(elementIdFromRoute);
|
|
} else if (
|
|
currentSelectedId &&
|
|
!normalizedElements.some((element) => element.id === currentSelectedId)
|
|
) {
|
|
// Current selection no longer valid
|
|
clearSelection();
|
|
}
|
|
// If current selection is still valid, do nothing (keep current)
|
|
// Update consolidated background state (replaces 8 separate setters)
|
|
updateBackgroundFromPage(activePage);
|
|
|
|
// Resolve blob URLs via hook for display (handles initial load and route changes)
|
|
// Only call if this page wasn't already initialized via switchToPage function
|
|
if (lastInitializedPageIdRef.current !== activePage.id) {
|
|
lastInitializedPageIdRef.current = activePage.id;
|
|
pageSwitchToPage({
|
|
id: activePage.id,
|
|
background_image_url: activePage.background_image_url,
|
|
background_video_url: activePage.background_video_url,
|
|
background_audio_url: activePage.background_audio_url,
|
|
});
|
|
}
|
|
}, [
|
|
activePage,
|
|
elementIdFromRoute,
|
|
uiElementDefaultsByType,
|
|
pageSwitchToPage,
|
|
clearSelection,
|
|
selectElement,
|
|
setElements,
|
|
updateBackgroundFromPage,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (allowedNavigationTypes.length !== 1) return;
|
|
const forcedType = allowedNavigationTypes[0];
|
|
|
|
setElements((prev) => {
|
|
let hasChanges = false;
|
|
const nextElements = prev.map((element) => {
|
|
if (
|
|
!isNavigationElementType(element.type) ||
|
|
element.type === forcedType
|
|
)
|
|
return element;
|
|
hasChanges = true;
|
|
return normalizeNavigationType(element, forcedType);
|
|
});
|
|
return hasChanges ? nextElements : prev;
|
|
});
|
|
}, [allowedNavigationTypes, normalizeNavigationType, setElements]);
|
|
|
|
// Element drag is now handled by useCanvasElementDrag hook
|
|
|
|
useEffect(() => {
|
|
if (isConstructorEditMode) return;
|
|
cancelElementDrag();
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
}, [isConstructorEditMode, cancelElementDrag, clearSelection]);
|
|
|
|
// Outside click detection to clear element/menu selection
|
|
// Ignore clicks on menu to allow menu item selection
|
|
useOutsideClick({
|
|
containerRef: elementEditorRef,
|
|
ignoreRefs: [menuRef],
|
|
ignoreDataAttribute: 'data-constructor-element-id',
|
|
selectedValue: selectedElementId,
|
|
onOutsideClick: useCallback(() => {
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
}, [clearSelection]),
|
|
enabled:
|
|
isConstructorEditMode &&
|
|
(!!selectedElementId || selectedMenuItem !== 'none'),
|
|
});
|
|
|
|
// Thin wrappers for hook functions (handle additional state like selectedMenuItem)
|
|
const selectElementForEdit = useCallback(
|
|
(elementId: string) => {
|
|
selectElement(elementId);
|
|
// Note: setSelectedMenuItem('none') is handled by onElementSelected callback
|
|
},
|
|
[selectElement],
|
|
);
|
|
|
|
const selectMenuItemForEdit = useCallback(
|
|
(item: EditorMenuItem) => {
|
|
clearSelection();
|
|
setSelectedMenuItem(item);
|
|
},
|
|
[clearSelection],
|
|
);
|
|
|
|
// createPage, saveConstructor, saveToStage are now provided by useConstructorPageActions hook
|
|
|
|
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
|
|
if (!isConstructorEditMode) return;
|
|
|
|
const currentElement = elements.find((item) => item.id === elementId);
|
|
if (!currentElement) return;
|
|
|
|
// Select the element for editing
|
|
selectElementForEdit(elementId);
|
|
|
|
// Start drag with current position
|
|
onElementDragStart(
|
|
event,
|
|
elementId,
|
|
currentElement.xPercent,
|
|
currentElement.yPercent,
|
|
);
|
|
};
|
|
|
|
// openTransitionPreviewForElement is now provided by useTransitionPreview hook
|
|
|
|
const openTransitionPreview = (direction: 'forward' | 'back') => {
|
|
if (
|
|
!selectedElement ||
|
|
(selectedElement.type !== 'navigation_next' &&
|
|
selectedElement.type !== 'navigation_prev')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
openTransitionPreviewForElement(selectedElement, direction);
|
|
};
|
|
|
|
const onCanvasElementClick = (element: CanvasElement) => {
|
|
if (!isConstructorEditMode) {
|
|
if (isNavigationElementType(element.type)) {
|
|
// Disable navigation while transition is playing or buffering
|
|
if (transitionPreview || isReverseBuffering) {
|
|
return;
|
|
}
|
|
if (element.navDisabled) {
|
|
return;
|
|
}
|
|
|
|
// Use shared navigation helpers
|
|
const direction = getNavigationDirection(element);
|
|
|
|
// Get navigation context from hook for history-based back navigation
|
|
const navContext = getNavigationContext();
|
|
|
|
// Pass history context for history-based back navigation
|
|
const navTarget = resolveNavigationTarget(element, pages, navContext);
|
|
|
|
if (!navTarget) {
|
|
// Back buttons always use history, forward buttons use target page
|
|
if (isBackNavigation(element)) {
|
|
if (!navContext.previousPageId) {
|
|
setErrorMessage(
|
|
'No previous page in history. Navigate to another page first.',
|
|
);
|
|
} else {
|
|
setErrorMessage(
|
|
'Previous page not found. It may have been deleted.',
|
|
);
|
|
}
|
|
} else {
|
|
setErrorMessage(
|
|
'No target page configured for this navigation button.',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// For back navigation, use transition from navTarget (the forward element that brought us here)
|
|
// For forward navigation, use the element's own transition settings
|
|
const transitionSource = isBackNavigation(element)
|
|
? {
|
|
type: element.type,
|
|
transitionVideoUrl: navTarget.transitionVideoUrl,
|
|
transitionReverseMode: navTarget.transitionReverseMode,
|
|
reverseVideoUrl: navTarget.reverseVideoUrl,
|
|
}
|
|
: element;
|
|
|
|
// Check if transition can be played using shared helper
|
|
if (!hasPlayableTransition(transitionSource, direction)) {
|
|
closeTransitionPreview();
|
|
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
|
|
// Pass isBack flag for proper history management
|
|
switchToPage(navTarget.page, navTarget.isBack).then(() => {
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
setErrorMessage('');
|
|
});
|
|
return;
|
|
}
|
|
|
|
openPreviewWithTarget(transitionSource, direction, navTarget.pageId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
selectElementForEdit(element.id);
|
|
};
|
|
|
|
// Handler for gallery card clicks
|
|
const handleGalleryCardClick = useCallback(
|
|
(element: CanvasElement, cardIndex: number) => {
|
|
if (element.galleryCards && element.galleryCards.length > 0) {
|
|
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Handler for gallery carousel button position changes (constructor only)
|
|
const handleGalleryCarouselButtonPositionChange = useCallback(
|
|
(button: 'prev' | 'next' | 'back', x: number, y: number) => {
|
|
if (!activeGalleryCarousel) return;
|
|
|
|
const positionPatch =
|
|
button === 'prev'
|
|
? { galleryCarouselPrevX: x, galleryCarouselPrevY: y }
|
|
: button === 'next'
|
|
? { galleryCarouselNextX: x, galleryCarouselNextY: y }
|
|
: { galleryCarouselBackX: x, galleryCarouselBackY: y };
|
|
|
|
updateSelectedElement(positionPatch);
|
|
|
|
// Update the active carousel element to reflect the new positions
|
|
setActiveGalleryCarousel((prev) =>
|
|
prev
|
|
? { ...prev, element: { ...prev.element, ...positionPatch } }
|
|
: null,
|
|
);
|
|
},
|
|
[activeGalleryCarousel, updateSelectedElement],
|
|
);
|
|
|
|
// Handler for carousel element button position changes (constructor only)
|
|
const handleCarouselButtonPositionChange = useCallback(
|
|
(elementId: string, button: 'prev' | 'next', x: number, y: number) => {
|
|
const positionPatch =
|
|
button === 'prev'
|
|
? { carouselPrevX: x, carouselPrevY: y }
|
|
: { carouselNextX: x, carouselNextY: y };
|
|
|
|
setElements((prev) =>
|
|
prev.map((el) =>
|
|
el.id === elementId ? { ...el, ...positionPatch } : el,
|
|
),
|
|
);
|
|
},
|
|
[setElements],
|
|
);
|
|
|
|
const isElementVisibleOnCanvas = (element: CanvasElement) =>
|
|
isElementVisibleAtTime(
|
|
canvasElapsedSec,
|
|
element.appearDelaySec,
|
|
element.appearDurationSec,
|
|
);
|
|
|
|
const isElementReadyForCanvasRender = (element: CanvasElement) => {
|
|
const isPreloadableIconElement =
|
|
(isNavigationElementType(element.type) ||
|
|
isTooltipElementType(element.type) ||
|
|
isDescriptionElementType(element.type)) &&
|
|
Boolean(element.iconUrl);
|
|
|
|
if (!isPreloadableIconElement) return true;
|
|
|
|
const playbackUrl = resolveAssetPlaybackUrl(element.iconUrl);
|
|
if (!playbackUrl) return true;
|
|
|
|
return Boolean(preloadedIconUrlMap[playbackUrl]);
|
|
};
|
|
|
|
// URL resolver that uses preloaded blob URLs when available
|
|
// Depends on readyUrlsVersion to re-render when blob URLs become ready after preload
|
|
const resolveUrlWithBlob = useCallback(
|
|
(url: string | undefined): string => {
|
|
if (!url) return '';
|
|
|
|
// Try to get blob URL from preload orchestrator (instant display)
|
|
// Check storage key first (most reliable), then resolved URL
|
|
const blobUrl =
|
|
preloadOrchestrator.getReadyBlobUrl(url) ||
|
|
preloadOrchestrator.getReadyBlobUrl(resolveAssetPlaybackUrl(url));
|
|
if (blobUrl) return blobUrl;
|
|
|
|
// Fall back to standard resolution
|
|
return resolveAssetPlaybackUrl(url);
|
|
},
|
|
[preloadOrchestrator, preloadOrchestrator.readyUrlsVersion],
|
|
);
|
|
|
|
const canvasBackgroundStyle: React.CSSProperties = {};
|
|
// Use user's selection (backgroundImageUrl etc) as source of truth for display.
|
|
// Resolve via blob cache when available, fall back to pageSwitch state during transitions.
|
|
const backgroundImageSrc = backgroundImageUrl
|
|
? resolveUrlWithBlob(backgroundImageUrl)
|
|
: pageSwitch.currentBgImageUrl;
|
|
const backgroundVideoSrc = backgroundVideoUrl
|
|
? resolveUrlWithBlob(backgroundVideoUrl)
|
|
: pageSwitch.currentBgVideoUrl;
|
|
const backgroundAudioSrc = backgroundAudioUrl
|
|
? resolveUrlWithBlob(backgroundAudioUrl)
|
|
: pageSwitch.currentBgAudioUrl;
|
|
|
|
const hasEditorSelection =
|
|
isConstructorEditMode &&
|
|
(Boolean(selectedElement) || selectedMenuItem !== 'none');
|
|
const editorTitle =
|
|
selectedMenuItem === 'background_image'
|
|
? 'Background image'
|
|
: selectedMenuItem === 'background_video'
|
|
? 'Background video'
|
|
: selectedMenuItem === 'background_audio'
|
|
? 'Background audio'
|
|
: selectedMenuItem === 'create_transition'
|
|
? 'Create transition'
|
|
: selectedElement?.label || 'Element editor';
|
|
|
|
// Background image is rendered by CanvasBackground component (same as runtime)
|
|
// No CSS background-image needed on canvas div
|
|
|
|
// Duration notes for UI display
|
|
const durationNotes = useMemo(
|
|
() => ({
|
|
backgroundVideo: backgroundVideoDurationNote,
|
|
backgroundAudio: backgroundAudioDurationNote,
|
|
selectedMedia: selectedMediaDurationNote,
|
|
selectedTransition: selectedTransitionDurationNote,
|
|
newTransition: newTransitionDurationNote,
|
|
}),
|
|
[
|
|
backgroundVideoDurationNote,
|
|
backgroundAudioDurationNote,
|
|
selectedMediaDurationNote,
|
|
selectedTransitionDurationNote,
|
|
newTransitionDurationNote,
|
|
],
|
|
);
|
|
|
|
// Transition creation state for context
|
|
const transitionCreationState = useMemo(
|
|
() => ({
|
|
name: newTransitionName,
|
|
videoUrl: newTransitionVideoUrl,
|
|
supportsReverse: newTransitionSupportsReverse,
|
|
isCreating: isCreatingTransition,
|
|
setName: setNewTransitionName,
|
|
setVideoUrl: setNewTransitionVideoUrl,
|
|
setSupportsReverse: setNewTransitionSupportsReverse,
|
|
create: () =>
|
|
createTransition({
|
|
name: newTransitionName,
|
|
videoUrl: newTransitionVideoUrl,
|
|
supportsReverse: newTransitionSupportsReverse,
|
|
durationSec: getDuration(newTransitionVideoUrl),
|
|
}),
|
|
}),
|
|
[
|
|
newTransitionName,
|
|
newTransitionVideoUrl,
|
|
newTransitionSupportsReverse,
|
|
isCreatingTransition,
|
|
createTransition,
|
|
getDuration,
|
|
],
|
|
);
|
|
|
|
// Build context value for ConstructorProvider
|
|
// This allows child components to access constructor state without prop drilling
|
|
const constructorContextValue: ConstructorContextValue = useMemo(
|
|
() => ({
|
|
// Project state
|
|
projectId,
|
|
|
|
// Page state
|
|
pages,
|
|
activePageId,
|
|
activePage,
|
|
setActivePageId,
|
|
|
|
// Background state
|
|
pageBackground,
|
|
setPageBackground,
|
|
updateBackgroundFromPage,
|
|
|
|
// Background convenience setters
|
|
setBackgroundImageUrl,
|
|
setBackgroundVideoUrl,
|
|
setBackgroundAudioUrl,
|
|
setBackgroundVideoSettings,
|
|
|
|
// Element state
|
|
elements,
|
|
setElements,
|
|
selectedElementId,
|
|
selectedElement,
|
|
selectElement,
|
|
clearSelection,
|
|
updateElement: (id: string, patch: Partial<CanvasElement>) => {
|
|
setElements((prev) =>
|
|
prev.map((el) => (el.id === id ? { ...el, ...patch } : el)),
|
|
);
|
|
},
|
|
removeElement: (id: string) => {
|
|
setElements((prev) => prev.filter((el) => el.id !== id));
|
|
if (selectedElementId === id) {
|
|
clearSelection();
|
|
}
|
|
},
|
|
updateSelectedElement,
|
|
removeSelectedElement,
|
|
|
|
// Menu state
|
|
selectedMenuItem,
|
|
setSelectedMenuItem,
|
|
isMenuOpen,
|
|
setIsMenuOpen,
|
|
|
|
// Editor state
|
|
elementEditorTab,
|
|
setElementEditorTab,
|
|
|
|
// Assets
|
|
assets,
|
|
isLoadingAssets: isDataLoading,
|
|
|
|
// Asset options (derived from assets)
|
|
assetOptions,
|
|
|
|
// Gallery/Carousel operations
|
|
galleryCards,
|
|
galleryInfoSpans,
|
|
carouselSlides,
|
|
|
|
// Duration resolver
|
|
getDuration,
|
|
|
|
// Duration notes
|
|
durationNotes,
|
|
|
|
// Transition preview
|
|
onPreviewTransition: openTransitionPreview,
|
|
|
|
// Transition creation
|
|
transitionCreation: transitionCreationState,
|
|
|
|
// Navigation settings
|
|
allowedNavigationTypes,
|
|
normalizeNavigationType,
|
|
|
|
// Actions
|
|
save: saveConstructor,
|
|
isSaving,
|
|
}),
|
|
[
|
|
projectId,
|
|
pages,
|
|
activePageId,
|
|
activePage,
|
|
pageBackground,
|
|
setPageBackground,
|
|
updateBackgroundFromPage,
|
|
setBackgroundImageUrl,
|
|
setBackgroundVideoUrl,
|
|
setBackgroundAudioUrl,
|
|
setBackgroundVideoSettings,
|
|
elements,
|
|
setElements,
|
|
selectedElementId,
|
|
selectedElement,
|
|
selectElement,
|
|
clearSelection,
|
|
updateSelectedElement,
|
|
removeSelectedElement,
|
|
selectedMenuItem,
|
|
isMenuOpen,
|
|
elementEditorTab,
|
|
assets,
|
|
isDataLoading,
|
|
assetOptions,
|
|
galleryCards,
|
|
galleryInfoSpans,
|
|
carouselSlides,
|
|
getDuration,
|
|
durationNotes,
|
|
openTransitionPreview,
|
|
transitionCreationState,
|
|
allowedNavigationTypes,
|
|
normalizeNavigationType,
|
|
saveConstructor,
|
|
isSaving,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<ConstructorProvider value={constructorContextValue}>
|
|
<Head>
|
|
<title>
|
|
{getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}
|
|
</title>
|
|
</Head>
|
|
<div className='relative w-screen h-screen bg-black overflow-hidden'>
|
|
<div className='absolute top-4 left-4 z-30 flex max-w-[80vw] flex-col gap-2'>
|
|
<p className='text-xs font-semibold text-gray-700'>
|
|
{projectName || 'Loading project...'}
|
|
</p>
|
|
{errorMessage ? (
|
|
<p className='rounded bg-red-50 px-2 py-1 text-xs text-red-600'>
|
|
{errorMessage}
|
|
</p>
|
|
) : null}
|
|
{successMessage ? (
|
|
<p className='rounded bg-green-50 px-2 py-1 text-xs text-green-700'>
|
|
{successMessage}
|
|
</p>
|
|
) : null}
|
|
|
|
{pages.length > 0 && isElementEditMode && (
|
|
<div className='flex items-center gap-2'>
|
|
<BaseButton
|
|
color='lightDark'
|
|
label='Back to Elements'
|
|
icon={mdiExitToApp}
|
|
href={pageElementsListHref}
|
|
/>
|
|
<BaseButton
|
|
color='info'
|
|
label={isSaving ? 'Saving...' : 'Save'}
|
|
icon={mdiContentSave}
|
|
onClick={saveConstructor}
|
|
disabled={isSaving}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{pages.length > 0 && !isElementEditMode && (
|
|
<ConstructorControlsPanel
|
|
projectId={projectId}
|
|
pages={pages}
|
|
activePageId={activePageId}
|
|
interactionMode={constructorInteractionMode}
|
|
position={constructorControlsPosition}
|
|
onPageChange={(pageId) => {
|
|
const page = pages.find((p) => p.id === pageId);
|
|
if (page) switchToPage(page);
|
|
}}
|
|
onModeChange={setConstructorInteractionMode}
|
|
onDragStart={onConstructorControlsDragStart}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
ref={canvasRef}
|
|
tabIndex={-1}
|
|
className={`z-20 overflow-clip ${hasFullWidthCarousel ? 'bg-transparent' : 'bg-black'}`}
|
|
style={{
|
|
...canvasCssVars,
|
|
...letterboxStyles,
|
|
...canvasBackgroundStyle,
|
|
}}
|
|
>
|
|
<BackdropPortalProvider>
|
|
<CanvasBackground
|
|
backgroundImageUrl={backgroundImageSrc}
|
|
backgroundVideoUrl={backgroundVideoSrc}
|
|
backgroundAudioUrl={backgroundAudioSrc}
|
|
previousBgImageUrl={pageSwitch.previousBgImageUrl}
|
|
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
|
|
isSwitching={pageSwitch.isSwitching}
|
|
isNewBgReady={pageSwitch.isNewBgReady}
|
|
isFadingIn={isFadingIn}
|
|
onBackgroundReady={() => {
|
|
pageSwitch.markBackgroundReady();
|
|
setIsBackgroundReady(true);
|
|
}}
|
|
videoAutoplay={backgroundVideoAutoplay}
|
|
videoLoop={backgroundVideoLoop}
|
|
videoMuted={backgroundVideoMuted}
|
|
videoStartTime={backgroundVideoStartTime}
|
|
videoEndTime={backgroundVideoEndTime}
|
|
/>
|
|
|
|
{/* Elements container - z-10 ensures they appear above backdrop layer */}
|
|
<div
|
|
className={`absolute inset-0 z-10 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
|
>
|
|
{isLoading ? (
|
|
<div className='absolute inset-0 flex items-center justify-center'>
|
|
<p className='text-sm text-gray-500'>
|
|
Loading constructor...
|
|
</p>
|
|
</div>
|
|
) : pages.length === 0 ? (
|
|
<div className='absolute inset-0 flex items-center justify-center'>
|
|
<BaseButton
|
|
color='info'
|
|
label={isCreatingPage ? 'Creating...' : 'Create First Page'}
|
|
icon={mdiPlus}
|
|
onClick={createPage}
|
|
disabled={isCreatingPage}
|
|
/>
|
|
</div>
|
|
) : (
|
|
elements.map((element) => {
|
|
const shouldRender =
|
|
selectedElementId === element.id ||
|
|
(isElementVisibleOnCanvas(element) &&
|
|
isElementReadyForCanvasRender(element));
|
|
if (!shouldRender) return null;
|
|
|
|
const isNavDisabled =
|
|
isNavigationElementType(element.type) &&
|
|
(element.navDisabled ||
|
|
Boolean(transitionPreview) ||
|
|
isReverseBuffering);
|
|
|
|
return (
|
|
<CanvasElementComponent
|
|
key={element.id}
|
|
element={element}
|
|
isSelected={selectedElementId === element.id}
|
|
isEditMode={isConstructorEditMode}
|
|
isDisabled={isNavDisabled}
|
|
onClick={() => onCanvasElementClick(element)}
|
|
onMouseDown={(event) =>
|
|
onElementMouseDown(event, element.id)
|
|
}
|
|
resolveUrl={resolveUrlWithBlob}
|
|
onGalleryCardClick={(cardIndex) =>
|
|
handleGalleryCardClick(element, cardIndex)
|
|
}
|
|
onCarouselButtonPositionChange={(button, x, y) =>
|
|
handleCarouselButtonPositionChange(
|
|
element.id,
|
|
button,
|
|
x,
|
|
y,
|
|
)
|
|
}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</BackdropPortalProvider>
|
|
</div>
|
|
|
|
{/* ElementEditorPanel now uses ConstructorContext for all state */}
|
|
{pages.length > 0 && hasEditorSelection && (
|
|
<ElementEditorPanel
|
|
elementEditorRef={elementEditorRef}
|
|
position={editorPosition}
|
|
isCollapsed={isEditorCollapsed}
|
|
onToggleCollapse={() => setIsEditorCollapsed((prev) => !prev)}
|
|
onDragStart={onElementEditorDragStart}
|
|
title={editorTitle}
|
|
/>
|
|
)}
|
|
|
|
{pages.length > 0 && !isElementEditMode && (
|
|
<ConstructorMenu
|
|
ref={menuRef}
|
|
position={menuPosition}
|
|
isOpen={isMenuOpen}
|
|
allowedNavigationTypes={allowedNavigationTypes}
|
|
isCreatingPage={isCreatingPage}
|
|
isSaving={isSaving}
|
|
isSavingToStage={isSavingToStage}
|
|
onDragStart={onMenuDragStart}
|
|
onToggleOpen={() => setIsMenuOpen((prev) => !prev)}
|
|
onSelectMenuItem={selectMenuItemForEdit}
|
|
onAddElement={addElement}
|
|
onCreatePage={createPage}
|
|
onSave={saveConstructor}
|
|
onSaveToStage={saveToStage}
|
|
onExit={() =>
|
|
router.push(
|
|
projectId
|
|
? `/projects/${projectId}`
|
|
: '/projects/projects-list',
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<TransitionPreviewOverlay
|
|
videoRef={transitionVideoRef}
|
|
isActive={Boolean(transitionPreview)}
|
|
isBuffering={isReverseBuffering}
|
|
letterboxStyles={letterboxStyles}
|
|
/>
|
|
|
|
{/* Gallery Carousel Overlay */}
|
|
{activeGalleryCarousel && (
|
|
<GalleryCarouselOverlay
|
|
cards={activeGalleryCarousel.element.galleryCards || []}
|
|
initialIndex={activeGalleryCarousel.initialIndex}
|
|
onClose={() => setActiveGalleryCarousel(null)}
|
|
resolveUrl={resolveUrlWithBlob}
|
|
prevIconUrl={activeGalleryCarousel.element.galleryCarouselPrevIconUrl}
|
|
nextIconUrl={activeGalleryCarousel.element.galleryCarouselNextIconUrl}
|
|
backIconUrl={activeGalleryCarousel.element.galleryCarouselBackIconUrl}
|
|
backLabel={
|
|
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
|
|
}
|
|
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
|
|
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
|
|
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
|
|
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
|
|
backX={activeGalleryCarousel.element.galleryCarouselBackX}
|
|
backY={activeGalleryCarousel.element.galleryCarouselBackY}
|
|
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
|
|
prevHeight={activeGalleryCarousel.element.galleryCarouselPrevHeight}
|
|
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
|
|
nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight}
|
|
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
|
|
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
|
|
isEditMode={isConstructorEditMode}
|
|
onButtonPositionChange={handleGalleryCarouselButtonPositionChange}
|
|
/>
|
|
)}
|
|
|
|
<style jsx>{`
|
|
.menu-action-btn {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
padding: 6px 8px;
|
|
border-radius: 6px;
|
|
color: #1f2937;
|
|
text-align: left;
|
|
}
|
|
|
|
.menu-action-btn:hover {
|
|
background: #f3f4f6;
|
|
}
|
|
`}</style>
|
|
</ConstructorProvider>
|
|
);
|
|
};
|
|
|
|
ConstructorPage.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
};
|
|
|
|
export default ConstructorPage;
|