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) => {
);
}
@@ -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 (