From 990839e9ca353cfa0bf78f7affe5d1885945e6ec Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 19 Mar 2026 08:58:15 +0000 Subject: [PATCH] Autosave: 20260319-085815 --- frontend/src/pages/constructor.tsx | 371 ++++++++++++++++++++++++----- 1 file changed, 308 insertions(+), 63 deletions(-) diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index 26fad9c..dc268a6 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -179,6 +179,8 @@ type ConstructorPageProps = { mode?: 'constructor' | 'element_edit'; }; +type ConstructorInteractionMode = 'edit' | 'interact'; + const parseJsonObject = (value?: unknown, fallback?: T): T => { if (!value) return (fallback || ({} as T)) as T; @@ -753,12 +755,23 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { Record >({}); const [canvasElapsedSec, setCanvasElapsedSec] = useState(0); + const [preloadedIconUrlMap, setPreloadedIconUrlMap] = useState< + Record + >({}); + const [constructorInteractionMode, setConstructorInteractionMode] = + useState('edit'); + const [constructorControlsPosition, setConstructorControlsPosition] = + useState({ x: 20, y: 20 }); 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 constructorControlsDragRef = useRef<{ + pointerOffsetX: number; + pointerOffsetY: number; + } | null>(null); const menuDragRef = useRef<{ pointerOffsetX: number; pointerOffsetY: number; @@ -773,11 +786,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { const didSetInitialCanvasFocus = useRef(false); const durationProbeInFlightRef = useRef>(new Set()); const pagePlaybackStartedAtRef = useRef(Date.now()); + const preloadedIconUrlsRef = useRef>(new Set()); const activePage = useMemo( () => pages.find((item) => item.id === activePageId) || null, [activePageId, pages], ); + const isConstructorEditMode = constructorInteractionMode === 'edit'; const allowedNavigationTypes = useMemo(() => { return ['navigation_next', 'navigation_prev']; }, []); @@ -792,6 +807,23 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { () => elements.find((element) => element.id === selectedElementId) || null, [elements, selectedElementId], ); + 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]); const normalizeNavigationElementType = useCallback( (element: CanvasElement, nextType: NavigationElementType): CanvasElement => { if (!isNavigationElementType(element.type)) return element; @@ -1136,6 +1168,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { useEffect(() => { if (typeof window === 'undefined') return; + setConstructorControlsPosition((prev) => { + if (prev.x > 0) return prev; + return { + x: 20, + y: 20, + }; + }); + setMenuPosition((prev) => { if (prev.x > 0) return prev; return { @@ -1183,6 +1223,62 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { return () => window.clearInterval(intervalId); }, [activePageId, isLoading]); + useEffect(() => { + if (typeof window === 'undefined') return; + + const targetSet = new Set(iconPreloadTargets); + const nextPreloaded = new Set(); + preloadedIconUrlsRef.current.forEach((url) => { + if (targetSet.has(url)) nextPreloaded.add(url); + }); + preloadedIconUrlsRef.current = nextPreloaded; + setPreloadedIconUrlMap(() => { + const nextMap: Record = {}; + nextPreloaded.forEach((url) => { + nextMap[url] = true; + }); + return nextMap; + }); + + if (!iconPreloadTargets.length) return; + + let isCancelled = false; + const preloadImages: HTMLImageElement[] = []; + + iconPreloadTargets.forEach((url) => { + if (preloadedIconUrlsRef.current.has(url)) return; + + const image = new Image(); + const markReady = () => { + if (isCancelled) return; + preloadedIconUrlsRef.current.add(url); + setPreloadedIconUrlMap((prev) => { + if (prev[url]) return prev; + return { + ...prev, + [url]: true, + }; + }); + }; + + image.onload = markReady; + image.onerror = () => { + console.error('Failed to preload icon asset:', url); + markReady(); + }; + image.src = url; + preloadImages.push(image); + }); + + return () => { + isCancelled = true; + preloadImages.forEach((image) => { + image.onload = null; + image.onerror = null; + }); + }; + }, [iconPreloadTargets]); + useEffect(() => { if (!activePage) { setElements([]); @@ -1334,6 +1430,23 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { useEffect(() => { const onPointerMove = (event: MouseEvent) => { + if (constructorControlsDragRef.current) { + const maxX = Math.max(window.innerWidth - 460, 0); + const maxY = Math.max(window.innerHeight - 64, 0); + const nextX = clamp( + event.clientX - constructorControlsDragRef.current.pointerOffsetX, + 0, + maxX, + ); + const nextY = clamp( + event.clientY - constructorControlsDragRef.current.pointerOffsetY, + 0, + maxY, + ); + setConstructorControlsPosition({ x: nextX, y: nextY }); + return; + } + if (menuDragRef.current) { const nextX = clamp( event.clientX - menuDragRef.current.pointerOffsetX, @@ -1385,6 +1498,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { }; const onPointerUp = () => { + constructorControlsDragRef.current = null; menuDragRef.current = null; editorDragRef.current = null; elementDragRef.current = null; @@ -1400,6 +1514,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { }, [isEditorCollapsed]); useEffect(() => { + if (isConstructorEditMode) return; + elementDragRef.current = null; + setSelectedElementId(''); + setSelectedMenuItem('none'); + }, [isConstructorEditMode]); + + useEffect(() => { + if (!isConstructorEditMode) return; if (!selectedElementId && selectedMenuItem === 'none') return; const onOutsideMouseDown = (event: MouseEvent) => { @@ -1421,7 +1543,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { window.addEventListener('mousedown', onOutsideMouseDown); return () => window.removeEventListener('mousedown', onOutsideMouseDown); - }, [selectedElementId, selectedMenuItem]); + }, [isConstructorEditMode, selectedElementId, selectedMenuItem]); const selectElementForEdit = (elementId: string) => { setSelectedElementId(elementId); @@ -1633,6 +1755,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ]); const onElementMouseDown = (event: React.MouseEvent, elementId: string) => { + if (!isConstructorEditMode) return; + event.preventDefault(); if (!canvasRef.current) return; const currentElement = elements.find((item) => item.id === elementId); @@ -1650,6 +1774,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { }; }; + const preventImageDragStart = (event: React.DragEvent) => { + event.preventDefault(); + }; + const updateSelectedElement = (patch: Partial) => { if (!selectedElementId) return; setElements((prev) => @@ -1739,6 +1867,44 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { updateSelectedElement({ carouselSlides: nextSlides }); }; + const openTransitionPreviewForElement = ( + element: CanvasElement, + direction: 'forward' | 'back', + ) => { + if (!isNavigationElementType(element.type)) return; + + if (!element.transitionVideoUrl) { + setErrorMessage( + 'Select transition video asset to preview transition playback.', + ); + return; + } + + if ( + direction === 'back' && + element.transitionReverseMode === 'separate_video' && + !element.reverseVideoUrl + ) { + setErrorMessage( + 'Select back-transition asset or switch reverse mode to Auto Reverse.', + ); + return; + } + + setTransitionPreview({ + videoUrl: element.transitionVideoUrl, + reverseMode: + direction === 'forward' + ? 'none' + : element.transitionReverseMode === 'separate_video' + ? 'separate' + : 'reverse', + reverseVideoUrl: element.reverseVideoUrl, + durationSec: element.transitionDurationSec, + title: `${element.navLabel || element.label} · ${direction}`, + }); + }; + const openTransitionPreview = (direction: 'forward' | 'back') => { if ( !selectedElement || @@ -1748,36 +1914,22 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { return; } - if (!selectedElement.transitionVideoUrl) { - setErrorMessage( - 'Select transition video asset to preview transition playback.', - ); + openTransitionPreviewForElement(selectedElement, direction); + }; + + const onCanvasElementClick = (element: CanvasElement) => { + if (!isConstructorEditMode) { + if (isNavigationElementType(element.type)) { + const direction = + element.navType === 'back' || element.type === 'navigation_prev' + ? 'back' + : 'forward'; + openTransitionPreviewForElement(element, direction); + } 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}`, - }); + selectElementForEdit(element.id); }; const onMenuDragStart = (event: React.MouseEvent) => { @@ -1790,6 +1942,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { }; }; + const onConstructorControlsDragStart = (event: React.MouseEvent) => { + const targetRect = ( + event.currentTarget as HTMLElement + ).getBoundingClientRect(); + constructorControlsDragRef.current = { + pointerOffsetX: event.clientX - targetRect.left, + pointerOffsetY: event.clientY - targetRect.top, + }; + }; + const onElementEditorDragStart = (event: React.MouseEvent) => { const target = event.target as HTMLElement; if (target.closest('button')) return; @@ -1816,7 +1978,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { Navigation icon ); } @@ -1846,6 +2010,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { src={resolveAssetPlaybackUrl(element.iconUrl)} alt='Tooltip icon' className='block h-auto w-auto max-h-[220px] max-w-[220px] object-contain' + draggable={false} + onDragStart={preventImageDragStart} /> ); } @@ -1870,6 +2036,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { src={resolveAssetPlaybackUrl(element.iconUrl)} alt='Description icon' className='block h-auto w-auto max-h-[220px] max-w-[220px] object-contain' + draggable={false} + onDragStart={preventImageDragStart} /> ); } @@ -2027,6 +2195,22 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { return canvasElapsedSec <= delay + duration; }; + const isElementReadyForCanvasRender = (element: CanvasElement) => { + const isPreloadableIconElement = + (element.type === 'navigation_next' || + element.type === 'navigation_prev' || + element.type === 'tooltip' || + element.type === 'description') && + Boolean(element.iconUrl); + + if (!isPreloadableIconElement) return true; + + const playbackUrl = resolveAssetPlaybackUrl(element.iconUrl); + if (!playbackUrl) return true; + + return Boolean(preloadedIconUrlMap[playbackUrl]); + }; + const canvasBackgroundStyle: React.CSSProperties = {}; const backgroundImageSrc = resolveAssetPlaybackUrl(backgroundImageUrl); const backgroundVideoSrc = resolveAssetPlaybackUrl(backgroundVideoUrl); @@ -2047,7 +2231,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { `Current audio · ${backgroundAudioUrl}`, ); const hasEditorSelection = - Boolean(selectedElement) || selectedMenuItem !== 'none'; + isConstructorEditMode && + (Boolean(selectedElement) || selectedMenuItem !== 'none'); const editorTitle = selectedMenuItem === 'background_image' ? 'Background image' @@ -2178,32 +2363,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {

) : null} - {pages.length > 0 && !isElementEditMode && ( -
- - -
- )} - {pages.length > 0 && isElementEditMode && (
{ )}
+ {pages.length > 0 && !isElementEditMode && ( +
+
+ + Constructor Controls + +
+
+
+ + +
+
+
+ + +
+ + {isConstructorEditMode + ? 'Drag & configure elements.' + : 'Click and interact with rendered elements.'} + +
+
+
+ )} +
{ elements.map((element) => { const shouldRender = selectedElementId === element.id || - isElementVisibleOnCanvas(element); + (isElementVisibleOnCanvas(element) && + isElementReadyForCanvasRender(element)); if (!shouldRender) return null; const hasIconDrivenSize = Boolean(element.iconUrl) && - (element.type === 'navigation_next' || - element.type === 'navigation_prev' || - element.type === 'tooltip' || + (element.type === 'tooltip' || element.type === 'description'); + const isNavigationIconElement = + Boolean(element.iconUrl) && + (element.type === 'navigation_next' || + element.type === 'navigation_prev'); return (