Autosave: 20260319-085815

This commit is contained in:
Flatlogic Bot 2026-03-19 08:58:15 +00:00
parent ce847e87d6
commit 990839e9ca

View File

@ -179,6 +179,8 @@ type ConstructorPageProps = {
mode?: 'constructor' | 'element_edit';
};
type ConstructorInteractionMode = 'edit' | 'interact';
const parseJsonObject = <T,>(value?: unknown, fallback?: T): T => {
if (!value) return (fallback || ({} as T)) as T;
@ -753,12 +755,23 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
Record<string, number | null>
>({});
const [canvasElapsedSec, setCanvasElapsedSec] = useState(0);
const [preloadedIconUrlMap, setPreloadedIconUrlMap] = useState<
Record<string, boolean>
>({});
const [constructorInteractionMode, setConstructorInteractionMode] =
useState<ConstructorInteractionMode>('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<Set<string>>(new Set());
const pagePlaybackStartedAtRef = useRef<number>(Date.now());
const preloadedIconUrlsRef = useRef<Set<string>>(new Set());
const activePage = useMemo(
() => pages.find((item) => item.id === activePageId) || null,
[activePageId, pages],
);
const isConstructorEditMode = constructorInteractionMode === 'edit';
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
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<string>();
preloadedIconUrlsRef.current.forEach((url) => {
if (targetSet.has(url)) nextPreloaded.add(url);
});
preloadedIconUrlsRef.current = nextPreloaded;
setPreloadedIconUrlMap(() => {
const nextMap: Record<string, boolean> = {};
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<HTMLImageElement>) => {
event.preventDefault();
};
const updateSelectedElement = (patch: Partial<CanvasElement>) => {
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) => {
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Navigation icon'
className='block h-auto w-auto max-h-[220px] max-w-[220px] object-contain'
className='block h-full w-full object-contain'
draggable={false}
onDragStart={preventImageDragStart}
/>
);
}
@ -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) => {
</p>
) : null}
{pages.length > 0 && !isElementEditMode && (
<div className='flex items-center gap-2'>
<select
className='border border-gray-300 rounded px-3 py-2 bg-white text-sm'
value={activePageId}
onChange={(event) => setActivePageId(event.target.value)}
>
{pages.map((page, index) => (
<option key={page.id} value={page.id}>
{page.name || `Page ${index + 1}`}
</option>
))}
</select>
<BaseButton
color='lightDark'
label='Exit to Assets'
icon={mdiExitToApp}
href={
projectId
? `/projects/${projectId}`
: '/projects/projects-list'
}
/>
</div>
)}
{pages.length > 0 && isElementEditMode && (
<div className='flex items-center gap-2'>
<BaseButton
@ -2223,6 +2382,81 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
)}
</div>
{pages.length > 0 && !isElementEditMode && (
<div
className='fixed z-40 w-[min(92vw,460px)] rounded-lg border border-gray-200 bg-white shadow-xl'
style={{
left: constructorControlsPosition.x,
top: constructorControlsPosition.y,
}}
>
<div
className='flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 px-3 py-2'
onMouseDown={onConstructorControlsDragStart}
>
<span className='text-xs font-bold uppercase'>
Constructor Controls
</span>
</div>
<div className='space-y-2 p-3'>
<div className='flex flex-wrap items-center gap-2'>
<select
className='rounded border border-gray-300 bg-white px-3 py-2 text-sm'
value={activePageId}
onChange={(event) => setActivePageId(event.target.value)}
>
{pages.map((page, index) => (
<option key={page.id} value={page.id}>
{page.name || `Page ${index + 1}`}
</option>
))}
</select>
<BaseButton
color='lightDark'
label='Exit to Assets'
icon={mdiExitToApp}
href={
projectId
? `/projects/${projectId}`
: '/projects/projects-list'
}
/>
</div>
<div className='flex flex-wrap items-center gap-2'>
<div className='inline-flex overflow-hidden rounded border border-gray-300 bg-white text-xs font-semibold'>
<button
type='button'
className={`px-3 py-1.5 ${
isConstructorEditMode
? 'bg-blue-600 text-white'
: 'text-gray-700 hover:bg-gray-50'
}`}
onClick={() => setConstructorInteractionMode('edit')}
>
Edit mode
</button>
<button
type='button'
className={`border-l border-gray-300 px-3 py-1.5 ${
!isConstructorEditMode
? 'bg-blue-600 text-white'
: 'text-gray-700 hover:bg-gray-50'
}`}
onClick={() => setConstructorInteractionMode('interact')}
>
Interact mode
</button>
</div>
<span className='text-[11px] text-gray-600'>
{isConstructorEditMode
? 'Drag & configure elements.'
: 'Click and interact with rendered elements.'}
</span>
</div>
</div>
</div>
)}
<div
ref={canvasRef}
tabIndex={-1}
@ -2279,23 +2513,30 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
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 (
<button
key={element.id}
type='button'
data-constructor-element-id={element.id}
className={`absolute border rounded text-xs font-semibold shadow cursor-move text-left ${
className={`absolute border rounded text-xs font-semibold shadow text-left ${
hasIconDrivenSize ? 'overflow-hidden p-0 leading-none' : 'px-3 py-2'
} ${
isNavigationIconElement ? 'flex items-center justify-center' : ''
} ${
isConstructorEditMode ? 'cursor-move' : 'cursor-pointer'
} ${
selectedElementId === element.id
? 'border-blue-500 bg-blue-50'
@ -2307,8 +2548,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
top: `${element.yPercent}%`,
transform: 'translate(-50%, -50%)',
}}
onMouseDown={(event) => onElementMouseDown(event, element.id)}
onClick={() => selectElementForEdit(element.id)}
onMouseDown={
isConstructorEditMode
? (event) => onElementMouseDown(event, element.id)
: undefined
}
onClick={() => onCanvasElementClick(element)}
>
{renderCanvasElementContent(element)}
</button>