diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index 4b8a84c..6e56509 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -1,18 +1,13 @@ import { - mdiChevronLeft, - mdiChevronRight, mdiContentSave, mdiExitToApp, - mdiImage, mdiImageMultiple, mdiMenu, - mdiMusic, mdiPlus, mdiSwapHorizontal, mdiText, mdiTooltipText, mdiViewCarousel, - mdiVideo, } from '@mdi/js'; import axios from 'axios'; import Head from 'next/head'; @@ -35,13 +30,27 @@ type TourPage = { background_loop?: boolean; }; +type ProjectAsset = { + id: string; + name?: string; + asset_type?: 'image' | 'video' | 'audio' | 'file'; + cdn_url?: string | null; +}; + +type AssetOption = { + value: string; + label: string; +}; + type CanvasElementType = | 'navigation_next' | 'navigation_prev' | 'gallery' | 'carousel' | 'tooltip' - | 'description'; + | 'description' + | 'video_player' + | 'audio_player'; type CanvasElement = { id: string; @@ -49,6 +58,35 @@ type CanvasElement = { label: string; xPercent: number; yPercent: number; + galleryCards?: GalleryCard[]; + carouselSlides?: CarouselSlide[]; + tooltipTitle?: string; + tooltipText?: string; + descriptionTitle?: string; + descriptionText?: string; + navLabel?: string; + targetPageId?: string; + transitionVideoUrl?: string; + transitionReverseMode?: 'auto_reverse' | 'separate_video'; + reverseVideoUrl?: string; + transitionDurationSec?: number; + mediaUrl?: string; + mediaAutoplay?: boolean; + mediaLoop?: boolean; + mediaMuted?: boolean; +}; + +type GalleryCard = { + id: string; + imageUrl: string; + title: string; + description: string; +}; + +type CarouselSlide = { + id: string; + imageUrl: string; + caption: string; }; type ConstructorSchema = { @@ -61,6 +99,16 @@ type DragElementState = { pointerOffsetY: number; }; +type TransitionPreviewState = { + videoUrl: string; + reverseMode: 'none' | 'reverse' | 'separate'; + reverseVideoUrl?: string; + durationSec?: number; + title: string; +}; + +type EditorMenuItem = 'none' | 'background_image' | 'background_video' | 'background_audio'; + const parseJsonObject = (value?: string, fallback?: T): T => { if (!value) return (fallback || ({} as T)) as T; @@ -83,6 +131,18 @@ const createLocalId = () => { return `constructor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }; +const getAssetLabel = (asset: ProjectAsset) => { + const baseName = asset.name?.trim() || 'Untitled asset'; + return `${baseName}${asset.cdn_url ? ` · ${asset.cdn_url}` : ''}`; +}; + +const addFallbackAssetOption = (options: AssetOption[], value?: string, fallbackLabel?: string): AssetOption[] => { + const normalizedValue = String(value || '').trim(); + if (!normalizedValue) return options; + if (options.some((option) => option.value === normalizedValue)) return options; + return [...options, { value: normalizedValue, label: fallbackLabel || `Custom URL · ${normalizedValue}` }]; +}; + const labelByType: Record = { navigation_next: 'Navigation: Forward', navigation_prev: 'Navigation: Back', @@ -90,11 +150,94 @@ const labelByType: Record = { carousel: 'Carousel', tooltip: 'Tooltip', description: 'Description', + video_player: 'Video Player', + audio_player: 'Audio Player', +}; + +const createDefaultElement = (type: CanvasElementType, index: number): CanvasElement => { + const base: CanvasElement = { + id: createLocalId(), + type, + label: labelByType[type], + xPercent: clamp(12 + index * 4, 5, 80), + yPercent: clamp(16 + index * 6, 8, 85), + }; + + if (type === 'gallery') { + return { + ...base, + galleryCards: [{ id: createLocalId(), imageUrl: '', title: 'Card 1', description: '' }], + }; + } + + if (type === 'carousel') { + return { + ...base, + carouselSlides: [{ id: createLocalId(), imageUrl: '', caption: 'Slide 1' }], + }; + } + + if (type === 'tooltip') { + return { + ...base, + tooltipTitle: 'Tooltip title', + tooltipText: 'Tooltip text', + }; + } + + if (type === 'description') { + return { + ...base, + descriptionTitle: 'Description title', + descriptionText: 'Description text', + }; + } + + if (type === 'navigation_next' || type === 'navigation_prev') { + return { + ...base, + navLabel: type === 'navigation_next' ? 'Forward' : 'Back', + transitionReverseMode: 'auto_reverse', + transitionDurationSec: 0.7, + }; + } + + if (type === 'video_player' || type === 'audio_player') { + return { + ...base, + mediaUrl: '', + mediaAutoplay: true, + mediaLoop: true, + mediaMuted: type === 'video_player', + }; + } + + return base; +}; + +const getElementButtonTitle = (element: CanvasElement) => { + if (element.type === 'gallery') { + return `${element.label} (${element.galleryCards?.length || 0})`; + } + + if (element.type === 'carousel') { + return `${element.label} (${element.carouselSlides?.length || 0})`; + } + + if (element.type === 'tooltip' && element.tooltipTitle) return element.tooltipTitle; + if (element.type === 'description' && element.descriptionTitle) return element.descriptionTitle; + if ((element.type === 'navigation_next' || element.type === 'navigation_prev') && element.navLabel) return element.navLabel; + if ((element.type === 'video_player' || element.type === 'audio_player') && element.mediaUrl) { + return `${element.label} · configured`; + } + + return element.label; }; const ConstructorPage = () => { const router = useRouter(); const canvasRef = useRef(null); + const elementEditorRef = useRef(null); const projectId = useMemo(() => { const value = router.query.projectId; @@ -109,6 +252,7 @@ const ConstructorPage = () => { }, [router.query.pageId]); const [pages, setPages] = useState([]); + const [assets, setAssets] = useState([]); const [activePageId, setActivePageId] = useState(''); const [projectName, setProjectName] = useState(''); @@ -116,21 +260,73 @@ const ConstructorPage = () => { const [backgroundImageUrl, setBackgroundImageUrl] = useState(''); const [backgroundVideoUrl, setBackgroundVideoUrl] = useState(''); const [backgroundAudioUrl, setBackgroundAudioUrl] = useState(''); + const [selectedElementId, setSelectedElementId] = useState(''); + const [selectedMenuItem, setSelectedMenuItem] = useState('none'); + const [transitionPreview, setTransitionPreview] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [isCreatingPage, setIsCreatingPage] = useState(false); const [isCreatingTransition, setIsCreatingTransition] = useState(false); + const [newTransitionName, setNewTransitionName] = useState(''); + const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState(''); + const [newTransitionSupportsReverse, setNewTransitionSupportsReverse] = useState(true); + const [newTransitionDurationSec, setNewTransitionDurationSec] = useState(0.7); const [errorMessage, setErrorMessage] = useState(''); const [successMessage, setSuccessMessage] = useState(''); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 110 }); const [isMenuOpen, setIsMenuOpen] = useState(false); + const [editorPosition, setEditorPosition] = useState({ x: 0, y: 72 }); + const [isEditorCollapsed, setIsEditorCollapsed] = useState(false); const menuDragRef = useRef<{ pointerOffsetX: number; pointerOffsetY: number } | null>(null); + const editorDragRef = useRef<{ pointerOffsetX: number; pointerOffsetY: number } | null>(null); const elementDragRef = useRef(null); + const transitionVideoRef = useRef(null); + const reverseAnimationFrame = useRef(null); const activePage = useMemo(() => pages.find((item) => item.id === activePageId) || null, [activePageId, pages]); + const selectedElement = useMemo( + () => elements.find((element) => element.id === selectedElementId) || null, + [elements, selectedElementId], + ); + const imageAssetOptions = useMemo( + () => + assets + .filter((asset) => asset.asset_type === 'image' && asset.cdn_url) + .map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })), + [assets], + ); + const videoAssetOptions = useMemo( + () => + assets + .filter((asset) => asset.asset_type === 'video' && asset.cdn_url) + .map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })), + [assets], + ); + const audioAssetOptions = useMemo( + () => + assets + .filter((asset) => asset.asset_type === 'audio' && asset.cdn_url) + .map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })), + [assets], + ); + const transitionVideoAssetOptions = useMemo(() => { + const tagged = assets + .filter((asset) => asset.asset_type === 'video' && asset.cdn_url && /\[TRANSITION\]/i.test(String(asset.name || ''))) + .map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })); + + if (tagged.length > 0) return tagged; + + return videoAssetOptions; + }, [assets, videoAssetOptions]); + + useEffect(() => { + if (newTransitionVideoUrl) return; + if (!transitionVideoAssetOptions.length) return; + setNewTransitionVideoUrl(transitionVideoAssetOptions[0].value); + }, [newTransitionVideoUrl, transitionVideoAssetOptions]); const loadData = useCallback(async () => { if (!projectId) return; @@ -140,14 +336,17 @@ const ConstructorPage = () => { setErrorMessage(''); setSuccessMessage(''); - const [projectResponse, pagesResponse] = await Promise.all([ + const [projectResponse, pagesResponse, assetsResponse] = await Promise.all([ axios.get(`/projects/${projectId}`), axios.get(`/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}`), + axios.get(`/assets?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`), ]); const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows) ? pagesResponse.data.rows : []; + const assetRows: ProjectAsset[] = Array.isArray(assetsResponse?.data?.rows) ? assetsResponse.data.rows : []; setProjectName(projectResponse?.data?.name || ''); setPages(pageRows); + setAssets(assetRows); const defaultPageId = pageIdFromRoute || pageRows[0]?.id || ''; setActivePageId(defaultPageId); @@ -157,6 +356,7 @@ const ConstructorPage = () => { console.error('Failed to load constructor data:', error); setErrorMessage(message); setPages([]); + setAssets([]); } finally { setIsLoading(false); } @@ -182,11 +382,20 @@ const ConstructorPage = () => { y: prev.y, }; }); + + setEditorPosition((prev) => { + if (prev.x > 0) return prev; + return { + x: Math.max(window.innerWidth - 400, 20), + y: prev.y, + }; + }); }, []); useEffect(() => { if (!activePage) { setElements([]); + setSelectedElementId(''); setBackgroundImageUrl(''); setBackgroundVideoUrl(''); setBackgroundAudioUrl(''); @@ -203,10 +412,46 @@ const ConstructorPage = () => { label: labelByType[item.type as CanvasElementType], xPercent: clamp(Number(item.xPercent || 0), 0, 100), yPercent: clamp(Number(item.yPercent || 0), 0, 100), + galleryCards: Array.isArray(item.galleryCards) + ? item.galleryCards.map((card: any, index: number) => ({ + id: String(card?.id || createLocalId()), + imageUrl: String(card?.imageUrl || ''), + title: String(card?.title || `Card ${index + 1}`), + description: String(card?.description || ''), + })) + : undefined, + carouselSlides: Array.isArray(item.carouselSlides) + ? item.carouselSlides.map((slide: any, index: number) => ({ + id: String(slide?.id || createLocalId()), + imageUrl: String(slide?.imageUrl || ''), + caption: String(slide?.caption || `Slide ${index + 1}`), + })) + : 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 : '', + 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 : item.type === 'video_player', })) : []; setElements(normalizedElements); + setSelectedMenuItem('none'); + setSelectedElementId((current) => { + if (!normalizedElements.length) return ''; + if (normalizedElements.some((element) => element.id === current)) return current; + return normalizedElements[0].id; + }); setBackgroundImageUrl(activePage.background_image_url || ''); setBackgroundVideoUrl(activePage.background_video_url || ''); setBackgroundAudioUrl(activePage.background_audio_url || ''); @@ -221,6 +466,14 @@ const ConstructorPage = () => { return; } + if (editorDragRef.current) { + const editorWidth = isEditorCollapsed ? 260 : 380; + const nextX = clamp(event.clientX - editorDragRef.current.pointerOffsetX, 0, window.innerWidth - editorWidth); + const nextY = clamp(event.clientY - editorDragRef.current.pointerOffsetY, 0, window.innerHeight - 60); + setEditorPosition({ x: nextX, y: nextY }); + return; + } + if (!elementDragRef.current || !canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); @@ -238,6 +491,7 @@ const ConstructorPage = () => { const onPointerUp = () => { menuDragRef.current = null; + editorDragRef.current = null; elementDragRef.current = null; }; @@ -248,19 +502,39 @@ const ConstructorPage = () => { window.removeEventListener('mousemove', onPointerMove); window.removeEventListener('mouseup', onPointerUp); }; - }, []); + }, [isEditorCollapsed]); + + useEffect(() => { + if (!selectedElementId && selectedMenuItem === 'none') return; + + const onOutsideMouseDown = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (!target) return; + if (elementEditorRef.current?.contains(target)) return; + const clickedElementId = target.closest('[data-constructor-element-id]')?.getAttribute('data-constructor-element-id'); + if (selectedElementId && clickedElementId && clickedElementId === selectedElementId) return; + setSelectedElementId(''); + setSelectedMenuItem('none'); + }; + + window.addEventListener('mousedown', onOutsideMouseDown); + return () => window.removeEventListener('mousedown', onOutsideMouseDown); + }, [selectedElementId, selectedMenuItem]); + + const selectElementForEdit = (elementId: string) => { + setSelectedElementId(elementId); + setSelectedMenuItem('none'); + }; + + const selectMenuItemForEdit = (item: EditorMenuItem) => { + setSelectedElementId(''); + setSelectedMenuItem(item); + }; const addElement = (type: CanvasElementType) => { - setElements((prev) => [ - ...prev, - { - id: createLocalId(), - type, - label: labelByType[type], - xPercent: clamp(12 + prev.length * 4, 5, 80), - yPercent: clamp(16 + prev.length * 6, 8, 85), - }, - ]); + const nextElement = createDefaultElement(type, elements.length); + setElements((prev) => [...prev, nextElement]); + selectElementForEdit(nextElement.id); setSuccessMessage('Element added. Drag it to set position.'); setErrorMessage(''); }; @@ -319,6 +593,15 @@ const ConstructorPage = () => { return; } + const sanitizedVideoUrl = String(newTransitionVideoUrl || '').trim(); + if (!sanitizedVideoUrl) { + setErrorMessage('Select a transition video asset first.'); + return; + } + + const sanitizedName = String(newTransitionName || '').trim() || `Transition ${Date.now().toString().slice(-4)}`; + const parsedDuration = Number(newTransitionDurationSec); + try { setIsCreatingTransition(true); setErrorMessage(''); @@ -328,16 +611,17 @@ const ConstructorPage = () => { project: projectId, environment: activePage?.environment || 'dev', source_key: '', - name: `Transition ${Date.now().toString().slice(-4)}`, - slug: '', - video_url: '', + name: sanitizedName, + slug: `transition-${Date.now().toString().slice(-4)}`, + video_url: sanitizedVideoUrl, audio_url: '', - supports_reverse: true, - duration_sec: '', + supports_reverse: Boolean(newTransitionSupportsReverse), + duration_sec: Number.isFinite(parsedDuration) && parsedDuration > 0 ? parsedDuration : 0.7, }; await axios.post('/transitions', { data: payload }); - setSuccessMessage('Transition created. Configure media in transitions section.'); + setSuccessMessage('Transition created.'); + setNewTransitionName(''); } catch (error: any) { const message = error?.response?.data?.message || error?.message || 'Failed to create transition.'; console.error('Failed to create transition from constructor:', error); @@ -345,7 +629,7 @@ const ConstructorPage = () => { } finally { setIsCreatingTransition(false); } - }, [activePage?.environment, projectId]); + }, [activePage?.environment, newTransitionDurationSec, newTransitionName, newTransitionSupportsReverse, newTransitionVideoUrl, projectId]); const saveConstructor = useCallback(async () => { if (!activePageId) { @@ -391,6 +675,7 @@ const ConstructorPage = () => { const currentElement = elements.find((item) => item.id === elementId); if (!currentElement) return; + selectElementForEdit(elementId); const rect = canvasRef.current.getBoundingClientRect(); const elementLeftPx = (currentElement.xPercent / 100) * rect.width; @@ -403,24 +688,100 @@ const ConstructorPage = () => { }; }; - const askBackgroundImage = () => { - const nextValue = window.prompt('Background image URL', backgroundImageUrl || ''); - if (nextValue === null) return; - setBackgroundImageUrl(nextValue.trim()); - if (nextValue.trim()) setBackgroundVideoUrl(''); + const updateSelectedElement = (patch: Partial) => { + if (!selectedElementId) return; + setElements((prev) => prev.map((item) => (item.id === selectedElementId ? { ...item, ...patch } : item))); }; - const askBackgroundVideo = () => { - const nextValue = window.prompt('Background video URL', backgroundVideoUrl || ''); - if (nextValue === null) return; - setBackgroundVideoUrl(nextValue.trim()); - if (nextValue.trim()) setBackgroundImageUrl(''); + const removeSelectedElement = () => { + if (!selectedElementId) return; + + let nextSelectedId = ''; + setElements((prev) => { + const filtered = prev.filter((item) => item.id !== selectedElementId); + nextSelectedId = filtered[0]?.id || ''; + return filtered; + }); + if (nextSelectedId) { + selectElementForEdit(nextSelectedId); + } else { + setSelectedElementId(''); + setSelectedMenuItem('none'); + } + setSuccessMessage('Element removed.'); }; - const askBackgroundAudio = () => { - const nextValue = window.prompt('Background audio URL (autoplay + loop)', backgroundAudioUrl || ''); - if (nextValue === null) return; - setBackgroundAudioUrl(nextValue.trim()); + const updateGalleryCard = (cardId: string, patch: Partial) => { + if (!selectedElement || selectedElement.type !== 'gallery') return; + const nextCards = (selectedElement.galleryCards || []).map((card) => (card.id === cardId ? { ...card, ...patch } : card)); + updateSelectedElement({ galleryCards: nextCards }); + }; + + const addGalleryCard = () => { + if (!selectedElement || selectedElement.type !== 'gallery') return; + const nextCards = [ + ...(selectedElement.galleryCards || []), + { id: createLocalId(), imageUrl: '', title: `Card ${(selectedElement.galleryCards || []).length + 1}`, description: '' }, + ]; + updateSelectedElement({ galleryCards: nextCards }); + }; + + const removeGalleryCard = (cardId: string) => { + if (!selectedElement || selectedElement.type !== 'gallery') return; + const nextCards = (selectedElement.galleryCards || []).filter((card) => card.id !== cardId); + updateSelectedElement({ galleryCards: nextCards }); + }; + + const updateCarouselSlide = (slideId: string, patch: Partial) => { + if (!selectedElement || selectedElement.type !== 'carousel') return; + const nextSlides = (selectedElement.carouselSlides || []).map((slide) => + slide.id === slideId ? { ...slide, ...patch } : slide, + ); + updateSelectedElement({ carouselSlides: nextSlides }); + }; + + const addCarouselSlide = () => { + if (!selectedElement || selectedElement.type !== 'carousel') return; + const nextSlides = [ + ...(selectedElement.carouselSlides || []), + { id: createLocalId(), imageUrl: '', caption: `Slide ${(selectedElement.carouselSlides || []).length + 1}` }, + ]; + updateSelectedElement({ carouselSlides: nextSlides }); + }; + + const removeCarouselSlide = (slideId: string) => { + if (!selectedElement || selectedElement.type !== 'carousel') return; + const nextSlides = (selectedElement.carouselSlides || []).filter((slide) => slide.id !== slideId); + updateSelectedElement({ carouselSlides: nextSlides }); + }; + + const openTransitionPreview = (direction: 'forward' | 'back') => { + if (!selectedElement || (selectedElement.type !== 'navigation_next' && selectedElement.type !== 'navigation_prev')) { + return; + } + + if (!selectedElement.transitionVideoUrl) { + setErrorMessage('Select transition video asset to preview transition playback.'); + return; + } + + if (direction === 'back' && selectedElement.transitionReverseMode === 'separate_video' && !selectedElement.reverseVideoUrl) { + setErrorMessage('Select back-transition asset or switch reverse mode to Auto Reverse.'); + return; + } + + setTransitionPreview({ + videoUrl: selectedElement.transitionVideoUrl, + reverseMode: + direction === 'forward' + ? 'none' + : selectedElement.transitionReverseMode === 'separate_video' + ? 'separate' + : 'reverse', + reverseVideoUrl: selectedElement.reverseVideoUrl, + durationSec: selectedElement.transitionDurationSec, + title: `${selectedElement.navLabel || selectedElement.label} · ${direction}`, + }); }; const onMenuDragStart = (event: React.MouseEvent) => { @@ -431,13 +792,118 @@ const ConstructorPage = () => { }; }; + const onElementEditorDragStart = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + if (target.closest('button')) return; + + const targetRect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + editorDragRef.current = { + pointerOffsetX: event.clientX - targetRect.left, + pointerOffsetY: event.clientY - targetRect.top, + }; + }; + const canvasBackgroundStyle: React.CSSProperties = {}; + const backgroundImageSelectOptions = addFallbackAssetOption(imageAssetOptions, backgroundImageUrl, `Current image · ${backgroundImageUrl}`); + const backgroundVideoSelectOptions = addFallbackAssetOption(videoAssetOptions, backgroundVideoUrl, `Current video · ${backgroundVideoUrl}`); + const backgroundAudioSelectOptions = addFallbackAssetOption(audioAssetOptions, backgroundAudioUrl, `Current audio · ${backgroundAudioUrl}`); + const hasEditorSelection = Boolean(selectedElement) || selectedMenuItem !== 'none'; + const editorTitle = + selectedMenuItem === 'background_image' + ? 'Background image' + : selectedMenuItem === 'background_video' + ? 'Background video' + : selectedMenuItem === 'background_audio' + ? 'Background audio' + : selectedElement?.label || 'Element editor'; + if (backgroundImageUrl) { canvasBackgroundStyle.backgroundImage = `url(${backgroundImageUrl})`; canvasBackgroundStyle.backgroundSize = 'cover'; canvasBackgroundStyle.backgroundPosition = 'center'; } + useEffect(() => { + const video = transitionVideoRef.current; + if (!transitionPreview || !video) return; + + let fallbackTimer: ReturnType | null = null; + + const cleanupReverseFrame = () => { + if (reverseAnimationFrame.current !== null) { + cancelAnimationFrame(reverseAnimationFrame.current); + reverseAnimationFrame.current = null; + } + }; + + const finishPreview = () => { + cleanupReverseFrame(); + setTransitionPreview(null); + }; + + const configuredDurationMs = (transitionPreview.durationSec && transitionPreview.durationSec > 0 ? transitionPreview.durationSec : 0.7) * 1000; + + const runReversePreview = () => { + cleanupReverseFrame(); + const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : Math.max(configuredDurationMs / 1000, 0.7); + const reverseSeconds = transitionPreview.durationSec && transitionPreview.durationSec > 0 ? transitionPreview.durationSec : duration; + const reverseMs = Math.max(reverseSeconds * 1000, 400); + const reverseRate = duration / reverseSeconds; + const startTime = performance.now(); + video.pause(); + video.currentTime = duration; + + const step = (now: number) => { + const elapsed = now - startTime; + const nextTime = Math.max(duration - (elapsed / 1000) * reverseRate, 0); + video.currentTime = nextTime; + + if (elapsed >= reverseMs || nextTime <= 0.001) { + finishPreview(); + return; + } + + reverseAnimationFrame.current = requestAnimationFrame(step); + }; + + reverseAnimationFrame.current = requestAnimationFrame(step); + }; + + const onLoadedMetadata = () => { + if (transitionPreview.reverseMode === 'reverse') { + runReversePreview(); + return; + } + + video.currentTime = 0; + video.play().catch((playError) => { + console.error('Transition preview playback failed:', playError); + fallbackTimer = setTimeout(finishPreview, configuredDurationMs); + }); + }; + + const onEnded = () => finishPreview(); + + video.addEventListener('loadedmetadata', onLoadedMetadata); + video.addEventListener('ended', onEnded); + fallbackTimer = setTimeout(finishPreview, configuredDurationMs + 500); + + return () => { + video.removeEventListener('loadedmetadata', onLoadedMetadata); + video.removeEventListener('ended', onEnded); + cleanupReverseFrame(); + if (fallbackTimer) clearTimeout(fallbackTimer); + }; + }, [transitionPreview]); + + useEffect(() => { + return () => { + if (reverseAnimationFrame.current !== null) { + cancelAnimationFrame(reverseAnimationFrame.current); + } + }; + }, []); + return ( <> @@ -498,16 +964,454 @@ const ConstructorPage = () => { )) )} + {pages.length > 0 && hasEditorSelection && ( +
+
+

{editorTitle}

+
+ + {selectedElement && ( + + )} +
+
+ + {!isEditorCollapsed && ( + <> + {selectedMenuItem === 'background_image' && ( +
+ + +
+ )} + + {selectedMenuItem === 'background_video' && ( +
+ + +
+ )} + + {selectedMenuItem === 'background_audio' && ( +
+ + +
+ )} + + {selectedElement && ( +
+ + updateSelectedElement({ label: event.target.value })} + /> +
+ )} + + {selectedElement && (selectedElement.type === 'navigation_next' || selectedElement.type === 'navigation_prev') && ( +
+
+ + updateSelectedElement({ navLabel: event.target.value })} + /> +
+
+ + +
+
+ + +
+
+ + +
+ {selectedElement.transitionReverseMode === 'separate_video' && ( +
+ + +
+ )} +
+ + updateSelectedElement({ transitionDurationSec: Number(event.target.value || 0.7) })} + /> +
+
+ openTransitionPreview('forward')} /> + openTransitionPreview('back')} /> +
+
+

Create next page transition

+ setNewTransitionName(event.target.value)} + /> + + + setNewTransitionDurationSec(Number(event.target.value || 0.7))} + /> + +
+
+ )} + + {selectedElement && selectedElement.type === 'tooltip' && ( +
+
+ + updateSelectedElement({ tooltipTitle: event.target.value })} + /> +
+
+ +