Revert to version c88f5d2

This commit is contained in:
Flatlogic Bot 2026-05-28 07:13:35 +00:00
parent 92e70775c1
commit e0f32356ba
34 changed files with 938 additions and 171 deletions

View File

@ -20,7 +20,9 @@ passport.use(
async (req, token, done) => { async (req, token, done) => {
try { try {
// Use lightweight auth query - only loads essential fields + permissions // Use lightweight auth query - only loads essential fields + permissions
const user = await UsersDBApi.findByForAuth({ email: token.user.email }); const user = await UsersDBApi.findByForAuth({
email: token.user.email,
});
if (user && user.disabled) { if (user && user.disabled) {
return done(new Error(`User '${user.email}' is disabled`)); return done(new Error(`User '${user.email}' is disabled`));

View File

@ -455,10 +455,16 @@ const downloadFile = async (req, res) => {
const chunkSize = end - start + 1; const chunkSize = end - start + 1;
res.status(206); res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${stats.size}`); res.setHeader(
'Content-Range',
`bytes ${start}-${end}/${stats.size}`,
);
res.setHeader('Content-Length', chunkSize); res.setHeader('Content-Length', chunkSize);
res.setHeader('ETag', etag); res.setHeader('ETag', etag);
res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`); res.setHeader(
'Cache-Control',
`public, max-age=${config.s3CacheMaxAge}`,
);
return fs.createReadStream(cachePath, { start, end }).pipe(res); return fs.createReadStream(cachePath, { start, end }).pipe(res);
} }
@ -484,12 +490,22 @@ const downloadFile = async (req, res) => {
const rangeHeader = req.headers.range; const rangeHeader = req.headers.range;
if (rangeHeader) { if (rangeHeader) {
// For Range requests, we need to get file size first via headObject // For Range requests, we need to get file size first via headObject
const headResult = await s3.download(privateUrl, { signal, headOnly: true }); const headResult = await s3.download(privateUrl, {
signal,
headOnly: true,
});
const totalSize = headResult.contentLength; const totalSize = headResult.contentLength;
if (!totalSize) { if (!totalSize) {
log.warn({ privateUrl }, 'Cannot determine file size for range request'); log.warn(
return res.status(500).send(createErrorResponse('Cannot determine file size', 'SIZE_UNKNOWN')); { privateUrl },
'Cannot determine file size for range request',
);
return res
.status(500)
.send(
createErrorResponse('Cannot determine file size', 'SIZE_UNKNOWN'),
);
} }
const range = parseRangeHeader(rangeHeader, totalSize); const range = parseRangeHeader(rangeHeader, totalSize);
@ -510,12 +526,18 @@ const downloadFile = async (req, res) => {
res.status(206); res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`); res.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`);
res.setHeader('Content-Length', chunkSize); res.setHeader('Content-Length', chunkSize);
if (rangeResult.contentType) res.setHeader('Content-Type', rangeResult.contentType); if (rangeResult.contentType)
res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`); res.setHeader('Content-Type', rangeResult.contentType);
res.setHeader(
'Cache-Control',
`public, max-age=${config.s3CacheMaxAge}`,
);
if (typeof rangeResult.body.pipe === 'function') { if (typeof rangeResult.body.pipe === 'function') {
return rangeResult.body.pipe(res); return rangeResult.body.pipe(res);
} else if (typeof rangeResult.body.transformToByteArray === 'function') { } else if (
typeof rangeResult.body.transformToByteArray === 'function'
) {
const bytes = await rangeResult.body.transformToByteArray(); const bytes = await rangeResult.body.transformToByteArray();
return res.send(Buffer.from(bytes)); return res.send(Buffer.from(bytes));
} else { } else {
@ -638,7 +660,9 @@ const downloadFile = async (req, res) => {
const localFilePath = path.join(config.uploadDir, privateUrl); const localFilePath = path.join(config.uploadDir, privateUrl);
if (!fs.existsSync(localFilePath)) { if (!fs.existsSync(localFilePath)) {
return res.status(404).send(createErrorResponse('File not found', 'NOT_FOUND')); return res
.status(404)
.send(createErrorResponse('File not found', 'NOT_FOUND'));
} }
const stats = fs.statSync(localFilePath); const stats = fs.statSync(localFilePath);

View File

@ -725,7 +725,11 @@ class TourPagesService extends BaseService {
* @returns {Promise<Object>} Status object with ready keys and their reversed URLs * @returns {Promise<Object>} Status object with ready keys and their reversed URLs
*/ */
static async checkReverseVideoStatus(storageKeys) { static async checkReverseVideoStatus(storageKeys) {
if (!storageKeys || !Array.isArray(storageKeys) || storageKeys.length === 0) { if (
!storageKeys ||
!Array.isArray(storageKeys) ||
storageKeys.length === 0
) {
return { ready: {}, pending: [], allReady: true }; return { ready: {}, pending: [], allReady: true };
} }

File diff suppressed because one or more lines are too long

View File

@ -10,11 +10,11 @@
import React from 'react'; import React from 'react';
import UiElementRenderer from '../UiElements/UiElementRenderer'; import UiElementRenderer from '../UiElements/UiElementRenderer';
import { useElementEffects } from '../../hooks/useElementEffects'; import { useElementEffects } from '../../hooks/useElementEffects';
import { useAppearAnimation } from '../../hooks/useAppearAnimation';
import { import {
buildTransitionStyle, buildTransitionStyle,
buildAppearAnimationStyle,
hasAnyEffects, hasAnyEffects,
type ElementEffectProperties, extractEffectProperties,
} from '../../lib/elementEffects'; } from '../../lib/elementEffects';
import type { CanvasElement as CanvasElementType } from '../../types/constructor'; import type { CanvasElement as CanvasElementType } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition'; import type { ResolvedTransitionSettings } from '../../types/transition';
@ -57,31 +57,38 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
pageTransitionSettings, pageTransitionSettings,
preloadCache, preloadCache,
}) => { }) => {
// Extract effect properties from element // Extract effect properties from element using helper
const effectProperties: Partial<ElementEffectProperties> = { const effectProperties = extractEffectProperties(
appearAnimation: element.appearAnimation, element as unknown as Record<string, unknown>,
appearAnimationDuration: element.appearAnimationDuration, );
appearAnimationEasing: element.appearAnimationEasing,
hoverScale: element.hoverScale, // Use appear animation hook (removes animation after completion to unlock properties)
hoverOpacity: element.hoverOpacity, const { animationStyle, onAnimationEnd, hasAnimationEnded } =
hoverBackgroundColor: element.hoverBackgroundColor, useAppearAnimation(
hoverColor: element.hoverColor, effectProperties,
hoverBoxShadow: element.hoverBoxShadow, element.id, // Reset animation when element changes
hoverTransitionDuration: element.hoverTransitionDuration, );
focusScale: element.focusScale,
focusOpacity: element.focusOpacity,
focusOutline: element.focusOutline,
focusBoxShadow: element.focusBoxShadow,
activeScale: element.activeScale,
activeOpacity: element.activeOpacity,
activeBackgroundColor: element.activeBackgroundColor,
};
// Use effects hook - disabled in edit mode to avoid interfering with dragging // Use effects hook - disabled in edit mode to avoid interfering with dragging
const { effectStyle, eventHandlers } = useElementEffects( const { effectStyle, eventHandlers, onPersistClick } = useElementEffects(
isEditMode ? {} : effectProperties, isEditMode ? {} : effectProperties,
isEditMode
? undefined
: {
resetKey: element.id,
appearAnimationCompleted:
hasAnimationEnded || !effectProperties.appearAnimation,
},
); );
// Combined click handler (only persist click in preview mode)
const handleClick = () => {
if (!isEditMode) {
onPersistClick();
}
onClick();
};
// Clamp position to canvas bounds (0-100%) // Clamp position to canvas bounds (0-100%)
const clamp = (value: number, min: number, max: number) => const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max); Math.min(Math.max(value, min), max);
@ -114,14 +121,6 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
}; };
} }
// Add appear animation (ALWAYS - for WYSIWYG)
if (effectProperties.appearAnimation) {
positionStyle = {
...positionStyle,
...buildAppearAnimationStyle(effectProperties),
};
}
// Handle keyboard interaction for accessibility // Handle keyboard interaction for accessibility
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
@ -130,29 +129,68 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
} }
}; };
// Check if appear animation uses transform (slide/scale need separate wrapper)
const needsAnimationWrapper =
effectProperties.appearAnimation &&
effectProperties.appearAnimation !== 'fade';
// Render content
const content = (
<UiElementRenderer
element={element}
resolveUrl={resolveUrl}
isSelected={isSelected}
isEditMode={isEditMode}
onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
);
// For slide/scale (uses transform), use separate wrapper to avoid conflict with positioning
if (needsAnimationWrapper) {
return (
<div
role='button'
tabIndex={0}
data-constructor-element-id={element.id}
className='absolute cursor-pointer'
style={positionStyle}
onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...(!isEditMode ? eventHandlers : {})}
>
<div style={animationStyle} onAnimationEnd={onAnimationEnd}>
{content}
</div>
</div>
);
}
// Fade or no animation: apply animation to positioning div
const combinedStyle = effectProperties.appearAnimation
? { ...positionStyle, ...animationStyle }
: positionStyle;
return ( return (
<div <div
role='button' role='button'
tabIndex={0} tabIndex={0}
data-constructor-element-id={element.id} data-constructor-element-id={element.id}
className='absolute cursor-pointer' className='absolute cursor-pointer'
style={positionStyle} style={combinedStyle}
onMouseDown={isEditMode ? onMouseDown : undefined} onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={onClick} onClick={handleClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onAnimationEnd={
effectProperties.appearAnimation ? onAnimationEnd : undefined
}
{...(!isEditMode ? eventHandlers : {})} {...(!isEditMode ? eventHandlers : {})}
> >
<UiElementRenderer {content}
element={element}
resolveUrl={resolveUrl}
isSelected={isSelected}
isEditMode={isEditMode}
onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
</div> </div>
); );
}; };

View File

@ -85,8 +85,8 @@ const CreatePageModal: React.FC<CreatePageModalProps> = ({
return ( return (
<CardBoxModal <CardBoxModal
title="Create page" title='Create page'
buttonColor="info" buttonColor='info'
buttonLabel={isCreating ? 'Creating...' : 'Create'} buttonLabel={isCreating ? 'Creating...' : 'Create'}
isConfirmDisabled={isConfirmDisabled} isConfirmDisabled={isConfirmDisabled}
isActive={isActive} isActive={isActive}
@ -95,23 +95,23 @@ const CreatePageModal: React.FC<CreatePageModalProps> = ({
> >
<div> <div>
<label <label
htmlFor="create-page-name" htmlFor='create-page-name'
className="block text-sm font-semibold mb-1" className='block text-sm font-semibold mb-1'
> >
Page name Page name
</label> </label>
<input <input
id="create-page-name" id='create-page-name'
type="text" type='text'
value={pageName} value={pageName}
onChange={(e) => handleNameChange(e.target.value)} onChange={(e) => handleNameChange(e.target.value)}
placeholder="Enter page name" placeholder='Enter page name'
className="w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800" className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800'
autoFocus autoFocus
maxLength={255} maxLength={255}
/> />
{(nameError || nameValidationError) && ( {(nameError || nameValidationError) && (
<p className="text-xs text-red-600 mt-1"> <p className='text-xs text-red-600 mt-1'>
{nameError || nameValidationError} {nameError || nameValidationError}
</p> </p>
)} )}

View File

@ -764,6 +764,21 @@ export function ElementEditorPanel({
activeOpacity: selectedElement.activeOpacity || '', activeOpacity: selectedElement.activeOpacity || '',
activeBackgroundColor: activeBackgroundColor:
selectedElement.activeBackgroundColor || '', selectedElement.activeBackgroundColor || '',
// Hover reveal values
hoverReveal: selectedElement.hoverReveal ? 'true' : '',
hoverRevealInitialOpacity:
selectedElement.hoverRevealInitialOpacity || '',
hoverRevealTargetOpacity:
selectedElement.hoverRevealTargetOpacity || '',
hoverRevealDuration:
selectedElement.hoverRevealDuration || '',
hoverRevealDelay: selectedElement.hoverRevealDelay || '',
hoverRevealPersist: selectedElement.hoverRevealPersist
? 'true'
: '',
hoverPersistOnClick: selectedElement.hoverPersistOnClick
? 'true'
: '',
// Slide transition values (gallery/carousel) // Slide transition values (gallery/carousel)
slideTransitionType: slideTransitionType:
selectedElement.type === 'gallery' selectedElement.type === 'gallery'
@ -858,6 +873,15 @@ export function ElementEditorPanel({
value || undefined, value || undefined,
}); });
} }
} else if (
prop === 'hoverReveal' ||
prop === 'hoverRevealPersist' ||
prop === 'hoverPersistOnClick'
) {
// Boolean properties - convert 'true' string to boolean
updateSelectedElement({
[prop]: value === 'true',
});
} else { } else {
// Standard effect properties // Standard effect properties
updateSelectedElement({ updateSelectedElement({

View File

@ -114,6 +114,98 @@ const EffectsSettingsSection: React.FC<EffectsSettingsSectionProps> = ({
placeholder='e.g. 0.2' placeholder='e.g. 0.2'
/> />
</FormField> </FormField>
<FormField label='Persist effects after click'>
<label className='inline-flex items-center gap-2'>
<input
type='checkbox'
checked={values.hoverPersistOnClick === 'true'}
onChange={(e) =>
onChange(
'hoverPersistOnClick',
e.target.checked ? 'true' : '',
)
}
/>
Keep hover effects visible after clicking
</label>
</FormField>
</div>
{/* Hover Reveal Subsection */}
<div className='mt-4 pt-4 border-t border-gray-200'>
<p className='text-xs font-medium text-gray-700 mb-3'>Hover Reveal</p>
<p className='text-xs text-gray-500 mb-3'>
Element starts hidden and reveals on hover. Useful for hints or
hidden navigation.
</p>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Enable Hover Reveal'>
<label className='inline-flex items-center gap-2'>
<input
type='checkbox'
checked={values.hoverReveal === 'true'}
onChange={(e) =>
onChange('hoverReveal', e.target.checked ? 'true' : '')
}
/>
Element starts invisible
</label>
</FormField>
<FormField label='Stay visible after hover'>
<label className='inline-flex items-center gap-2'>
<input
type='checkbox'
checked={values.hoverRevealPersist === 'true'}
onChange={(e) =>
onChange(
'hoverRevealPersist',
e.target.checked ? 'true' : '',
)
}
disabled={values.hoverReveal !== 'true'}
/>
Persist visibility
</label>
</FormField>
<FormField label='Initial opacity'>
<input
value={values.hoverRevealInitialOpacity || ''}
onChange={(e) =>
onChange('hoverRevealInitialOpacity', e.target.value)
}
placeholder='0'
disabled={values.hoverReveal !== 'true'}
/>
</FormField>
<FormField label='Target opacity'>
<input
value={values.hoverRevealTargetOpacity || ''}
onChange={(e) =>
onChange('hoverRevealTargetOpacity', e.target.value)
}
placeholder='1'
disabled={values.hoverReveal !== 'true'}
/>
</FormField>
<FormField label='Reveal duration (sec)'>
<input
value={values.hoverRevealDuration || ''}
onChange={(e) =>
onChange('hoverRevealDuration', e.target.value)
}
placeholder='0.3'
disabled={values.hoverReveal !== 'true'}
/>
</FormField>
<FormField label='Reveal delay (sec)'>
<input
value={values.hoverRevealDelay || ''}
onChange={(e) => onChange('hoverRevealDelay', e.target.value)}
placeholder='0'
disabled={values.hoverReveal !== 'true'}
/>
</FormField>
</div>
</div> </div>
</div> </div>

View File

@ -152,6 +152,108 @@ const EffectsSettingsSectionCompact: React.FC<
placeholder='0.2' placeholder='0.2'
/> />
</div> </div>
<div className='col-span-2'>
<label className='mb-1 flex items-center gap-2 text-[10px] text-white/60'>
<input
type='checkbox'
checked={values.hoverPersistOnClick === 'true'}
onChange={(e) =>
onChange(
'hoverPersistOnClick',
e.target.checked ? 'true' : '',
)
}
className='rounded border-gray-300'
/>
Persist effects after click
</label>
</div>
{/* Hover Reveal Subsection */}
<div className='col-span-2 mt-2 pt-2 border-t border-white/20'>
<p className='mb-2 text-[10px] font-medium text-white/80'>
Hover Reveal
</p>
</div>
<div className='col-span-2'>
<label className='mb-1 flex items-center gap-2 text-[10px] text-white/60'>
<input
type='checkbox'
checked={values.hoverReveal === 'true'}
onChange={(e) =>
onChange('hoverReveal', e.target.checked ? 'true' : '')
}
className='rounded border-gray-300'
/>
Enable Hover Reveal
</label>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Initial Opacity
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.hoverRevealInitialOpacity || ''}
onChange={(e) =>
onChange('hoverRevealInitialOpacity', e.target.value)
}
placeholder='0'
disabled={values.hoverReveal !== 'true'}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Target Opacity
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.hoverRevealTargetOpacity || ''}
onChange={(e) =>
onChange('hoverRevealTargetOpacity', e.target.value)
}
placeholder='1'
disabled={values.hoverReveal !== 'true'}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Duration (sec)
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.hoverRevealDuration || ''}
onChange={(e) => onChange('hoverRevealDuration', e.target.value)}
placeholder='0.3'
disabled={values.hoverReveal !== 'true'}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Delay (sec)
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.hoverRevealDelay || ''}
onChange={(e) => onChange('hoverRevealDelay', e.target.value)}
placeholder='0'
disabled={values.hoverReveal !== 'true'}
/>
</div>
<div className='col-span-2'>
<label className='mb-1 flex items-center gap-2 text-[10px] text-white/60'>
<input
type='checkbox'
checked={values.hoverRevealPersist === 'true'}
onChange={(e) =>
onChange('hoverRevealPersist', e.target.checked ? 'true' : '')
}
className='rounded border-gray-300'
disabled={values.hoverReveal !== 'true'}
/>
Stay visible after hover (persist)
</label>
</div>
</div> </div>
</div> </div>

View File

@ -53,6 +53,15 @@ export interface EffectsSettingsFormValues {
activeScale?: string; activeScale?: string;
activeOpacity?: string; activeOpacity?: string;
activeBackgroundColor?: string; activeBackgroundColor?: string;
// Hover Reveal
hoverReveal?: string;
hoverRevealInitialOpacity?: string;
hoverRevealTargetOpacity?: string;
hoverRevealDuration?: string;
hoverRevealDelay?: string;
hoverRevealPersist?: string;
// Persist hover effects after click
hoverPersistOnClick?: string;
// Slide transition override (Gallery/Carousel only) // Slide transition override (Gallery/Carousel only)
// These override page transition settings for this element's slides // These override page transition settings for this element's slides
slideTransitionType?: string; slideTransitionType?: string;

View File

@ -87,6 +87,16 @@ interface FormState {
activeOpacity: string; activeOpacity: string;
activeBackgroundColor: string; activeBackgroundColor: string;
// Hover Reveal settings
hoverReveal: boolean;
hoverRevealInitialOpacity: string;
hoverRevealTargetOpacity: string;
hoverRevealDuration: string;
hoverRevealDelay: string;
hoverRevealPersist: boolean;
// Persist hover effects after click
hoverPersistOnClick: boolean;
// Navigation settings // Navigation settings
iconUrl: string; iconUrl: string;
navLabel: string; navLabel: string;
@ -182,6 +192,14 @@ const initialState: FormState = {
activeScale: '', activeScale: '',
activeOpacity: '', activeOpacity: '',
activeBackgroundColor: '', activeBackgroundColor: '',
// Hover Reveal settings
hoverReveal: false,
hoverRevealInitialOpacity: '',
hoverRevealTargetOpacity: '',
hoverRevealDuration: '',
hoverRevealDelay: '',
hoverRevealPersist: false,
hoverPersistOnClick: false,
iconUrl: '', iconUrl: '',
navLabel: '', navLabel: '',
navLabelFontFamily: '', navLabelFontFamily: '',
@ -284,6 +302,18 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
activeScale: String(settings.activeScale || ''), activeScale: String(settings.activeScale || ''),
activeOpacity: String(settings.activeOpacity || ''), activeOpacity: String(settings.activeOpacity || ''),
activeBackgroundColor: String(settings.activeBackgroundColor || ''), activeBackgroundColor: String(settings.activeBackgroundColor || ''),
// Hover Reveal settings
hoverReveal: Boolean(settings.hoverReveal),
hoverRevealInitialOpacity: String(
settings.hoverRevealInitialOpacity || '',
),
hoverRevealTargetOpacity: String(
settings.hoverRevealTargetOpacity || '',
),
hoverRevealDuration: String(settings.hoverRevealDuration || ''),
hoverRevealDelay: String(settings.hoverRevealDelay || ''),
hoverRevealPersist: Boolean(settings.hoverRevealPersist),
hoverPersistOnClick: Boolean(settings.hoverPersistOnClick),
appearDelaySec: String(settings.appearDelaySec ?? 0), appearDelaySec: String(settings.appearDelaySec ?? 0),
appearDurationSec: appearDurationSec:
settings.appearDurationSec === null || settings.appearDurationSec === null ||
@ -422,6 +452,14 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
activeScale: state.activeScale, activeScale: state.activeScale,
activeOpacity: state.activeOpacity, activeOpacity: state.activeOpacity,
activeBackgroundColor: state.activeBackgroundColor, activeBackgroundColor: state.activeBackgroundColor,
// Convert booleans to strings for form compatibility
hoverReveal: state.hoverReveal ? 'true' : '',
hoverRevealInitialOpacity: state.hoverRevealInitialOpacity,
hoverRevealTargetOpacity: state.hoverRevealTargetOpacity,
hoverRevealDuration: state.hoverRevealDuration,
hoverRevealDelay: state.hoverRevealDelay,
hoverRevealPersist: state.hoverRevealPersist ? 'true' : '',
hoverPersistOnClick: state.hoverPersistOnClick ? 'true' : '',
}; };
}, [state]); }, [state]);
@ -624,6 +662,30 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
if (activeBackgroundColorValue) if (activeBackgroundColorValue)
settings.activeBackgroundColor = activeBackgroundColorValue; settings.activeBackgroundColor = activeBackgroundColorValue;
// Hover Reveal properties (booleans saved directly, strings trimmed)
if (state.hoverReveal) settings.hoverReveal = true;
const hoverRevealInitialOpacityValue = toOptionalTrimmed(
state.hoverRevealInitialOpacity,
);
const hoverRevealTargetOpacityValue = toOptionalTrimmed(
state.hoverRevealTargetOpacity,
);
const hoverRevealDurationValue = toOptionalTrimmed(
state.hoverRevealDuration,
);
const hoverRevealDelayValue = toOptionalTrimmed(state.hoverRevealDelay);
if (hoverRevealInitialOpacityValue)
settings.hoverRevealInitialOpacity = hoverRevealInitialOpacityValue;
if (hoverRevealTargetOpacityValue)
settings.hoverRevealTargetOpacity = hoverRevealTargetOpacityValue;
if (hoverRevealDurationValue)
settings.hoverRevealDuration = hoverRevealDurationValue;
if (hoverRevealDelayValue)
settings.hoverRevealDelay = hoverRevealDelayValue;
if (state.hoverRevealPersist) settings.hoverRevealPersist = true;
if (state.hoverPersistOnClick) settings.hoverPersistOnClick = true;
// Navigation type settings // Navigation type settings
if (isNavigationType) { if (isNavigationType) {
settings.iconUrl = state.iconUrl.trim(); settings.iconUrl = state.iconUrl.trim();

View File

@ -9,11 +9,11 @@
import React from 'react'; import React from 'react';
import UiElementRenderer from './UiElements/UiElementRenderer'; import UiElementRenderer from './UiElements/UiElementRenderer';
import { useElementEffects } from '../hooks/useElementEffects'; import { useElementEffects } from '../hooks/useElementEffects';
import { useAppearAnimation } from '../hooks/useAppearAnimation';
import { import {
buildTransitionStyle, buildTransitionStyle,
buildAppearAnimationStyle,
hasAnyEffects, hasAnyEffects,
type ElementEffectProperties, extractEffectProperties,
} from '../lib/elementEffects'; } from '../lib/elementEffects';
import type { CanvasElement } from '../types/constructor'; import type { CanvasElement } from '../types/constructor';
import type { ResolvedTransitionSettings } from '../types/transition'; import type { ResolvedTransitionSettings } from '../types/transition';
@ -52,28 +52,33 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
const yPercent = clamp(element.yPercent ?? 50, 0, 100); const yPercent = clamp(element.yPercent ?? 50, 0, 100);
const rotation = element.rotation ?? 0; const rotation = element.rotation ?? 0;
// Extract effect properties from element // Extract effect properties from element using helper
const effectProperties: Partial<ElementEffectProperties> = { const effectProperties = extractEffectProperties(
appearAnimation: element.appearAnimation, element as unknown as Record<string, unknown>,
appearAnimationDuration: element.appearAnimationDuration, );
appearAnimationEasing: element.appearAnimationEasing,
hoverScale: element.hoverScale,
hoverOpacity: element.hoverOpacity,
hoverBackgroundColor: element.hoverBackgroundColor,
hoverColor: element.hoverColor,
hoverBoxShadow: element.hoverBoxShadow,
hoverTransitionDuration: element.hoverTransitionDuration,
focusScale: element.focusScale,
focusOpacity: element.focusOpacity,
focusOutline: element.focusOutline,
focusBoxShadow: element.focusBoxShadow,
activeScale: element.activeScale,
activeOpacity: element.activeOpacity,
activeBackgroundColor: element.activeBackgroundColor,
};
// Use effects hook for interactive states // Use appear animation hook (removes animation after completion to unlock properties)
const { effectStyle, eventHandlers } = useElementEffects(effectProperties); const { animationStyle, onAnimationEnd, hasAnimationEnded } =
useAppearAnimation(
effectProperties,
element.id, // Reset animation when element changes
);
// Use effects hook for interactive states with animation coordination
const { effectStyle, eventHandlers, onPersistClick } = useElementEffects(
effectProperties,
{
resetKey: element.id, // Reset reveal on element change
appearAnimationCompleted:
hasAnimationEnded || !effectProperties.appearAnimation,
},
);
// Combined click handler
const handleClick = () => {
onPersistClick(); // Toggle persistence state
onClick(); // Original navigation action
};
// Build base position style // Build base position style
let positionStyle: React.CSSProperties = { let positionStyle: React.CSSProperties = {
@ -99,28 +104,58 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
positionStyle = { ...positionStyle, ...transitionStyle }; positionStyle = { ...positionStyle, ...transitionStyle };
} }
// Add appear animation if configured // Check if appear animation uses transform (slide/scale need separate wrapper)
if (effectProperties.appearAnimation) { const needsAnimationWrapper =
const animationStyle = buildAppearAnimationStyle(effectProperties); effectProperties.appearAnimation &&
positionStyle = { ...positionStyle, ...animationStyle }; effectProperties.appearAnimation !== 'fade';
// Render content (with or without animation wrapper)
const content = (
<UiElementRenderer
element={element}
resolveUrl={resolveUrl}
onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
);
// For fade animation (opacity only), apply to positioning div
// For slide/scale (uses transform), use separate wrapper to avoid conflict
if (needsAnimationWrapper) {
return (
<div
className='absolute cursor-pointer'
style={positionStyle}
onClick={handleClick}
tabIndex={0}
{...eventHandlers}
>
<div style={animationStyle} onAnimationEnd={onAnimationEnd}>
{content}
</div>
</div>
);
} }
// Fade or no animation: apply animation to positioning div
const combinedStyle = effectProperties.appearAnimation
? { ...positionStyle, ...animationStyle }
: positionStyle;
return ( return (
<div <div
className='absolute cursor-pointer' className='absolute cursor-pointer'
style={positionStyle} style={combinedStyle}
onClick={onClick} onClick={handleClick}
tabIndex={0} tabIndex={0}
onAnimationEnd={
effectProperties.appearAnimation ? onAnimationEnd : undefined
}
{...eventHandlers} {...eventHandlers}
> >
<UiElementRenderer {content}
element={element}
resolveUrl={resolveUrl}
onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
</div> </div>
); );
}; };

View File

@ -286,7 +286,11 @@ export default function RuntimePresentation({
} = navState; } = navState;
// Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern) // Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern)
const { isBuffering, isVideoReady, phase: transitionPhase } = useTransitionPlayback({ const {
isBuffering,
isVideoReady,
phase: transitionPhase,
} = useTransitionPlayback({
videoRef: transitionVideoRef, videoRef: transitionVideoRef,
transition: transitionPreview transition: transitionPreview
? { ? {
@ -549,7 +553,6 @@ export default function RuntimePresentation({
// navShowElements: true when phase is 'idle' or 'fading_in' // navShowElements: true when phase is 'idle' or 'fading_in'
const areTransitionsReady = preloadOrchestrator?.areTransitionsReady ?? true; const areTransitionsReady = preloadOrchestrator?.areTransitionsReady ?? true;
const handleElementClick = useCallback( const handleElementClick = useCallback(
(element: CanvasElement) => { (element: CanvasElement) => {
// Block navigation while transition is actively playing or buffering // Block navigation while transition is actively playing or buffering
@ -849,7 +852,8 @@ export default function RuntimePresentation({
preloadOrchestrator preloadOrchestrator
? { ? {
getReadyBlob: preloadOrchestrator.getReadyBlob, getReadyBlob: preloadOrchestrator.getReadyBlob,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, getCachedBlobUrl:
preloadOrchestrator.getCachedBlobUrl,
} }
: undefined : undefined
} }

View File

@ -901,9 +901,7 @@ const TourFlowManager = () => {
maxLength={255} maxLength={255}
/> />
{nameValidationError && ( {nameValidationError && (
<p className='text-xs text-red-600 mt-1'> <p className='text-xs text-red-600 mt-1'>{nameValidationError}</p>
{nameValidationError}
</p>
)} )}
</div> </div>
</CardBoxModal> </CardBoxModal>
@ -913,7 +911,9 @@ const TourFlowManager = () => {
title='Edit page name' title='Edit page name'
buttonColor='info' buttonColor='info'
buttonLabel={isSavingPageName ? 'Saving...' : 'Save'} buttonLabel={isSavingPageName ? 'Saving...' : 'Save'}
isConfirmDisabled={Boolean(editNameValidationError) || isSavingPageName} isConfirmDisabled={
Boolean(editNameValidationError) || isSavingPageName
}
isActive={isEditPageModalActive} isActive={isEditPageModalActive}
onConfirm={handleSavePageName} onConfirm={handleSavePageName}
onCancel={isSavingPageName ? undefined : closeEditPageModal} onCancel={isSavingPageName ? undefined : closeEditPageModal}
@ -964,9 +964,10 @@ const TourFlowManager = () => {
const canDelete = const canDelete =
entry.type === 'page' ? canDeletePage : canDeleteTransition; entry.type === 'page' ? canDeletePage : canDeleteTransition;
const isDeleting = deletingId === entry.id; const isDeleting = deletingId === entry.id;
const pageData = entry.type === 'page' const pageData =
? pages.find((p) => p.id === entry.id) entry.type === 'page'
: null; ? pages.find((p) => p.id === entry.id)
: null;
return ( return (
<li key={`${entry.type}-${entry.id}`}> <li key={`${entry.type}-${entry.id}`}>

View File

@ -27,11 +27,7 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
className, className,
style, style,
}) => { }) => {
const { const { videoRef, resolvedUrl, isBuffering } = useVideoPlayer({
videoRef,
resolvedUrl,
isBuffering,
} = useVideoPlayer({
sourceUrl: element.mediaUrl, sourceUrl: element.mediaUrl,
preloadCache, preloadCache,
autoplay: Boolean(element.mediaAutoplay), autoplay: Boolean(element.mediaAutoplay),

View File

@ -290,11 +290,9 @@
@keyframes element-fade-in { @keyframes element-fade-in {
from { from {
opacity: 0; opacity: 0;
transform: translate3d(0, 0, 0);
} }
to { to {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0);
} }
} }

View File

@ -0,0 +1,108 @@
/**
* useAppearAnimation Hook
*
* Manages appear animation lifecycle to prevent conflicts with interactive effects.
*
* Problem: CSS animations with `animation-fill-mode: forwards` lock animated
* properties (opacity, transform), preventing CSS transitions from working
* on hover/focus/active states.
*
* Solution: Track animation completion via `animationend` event and remove
* the animation style after it completes, unlocking properties for transitions.
*
* Cross-browser: Uses standard `animationend` event supported by all modern
* browsers (Chrome, Firefox, Safari, Edge, iOS Safari, Android Chrome).
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { CSSProperties, AnimationEventHandler } from 'react';
import {
buildAppearAnimationStyle,
type ElementEffectProperties,
} from '../lib/elementEffects';
interface UseAppearAnimationResult {
/** Animation style to apply (empty after animation completes) */
animationStyle: CSSProperties;
/** Handler to attach to element's onAnimationEnd */
onAnimationEnd: AnimationEventHandler<HTMLElement>;
/** Whether the appear animation has completed */
hasAnimationEnded: boolean;
}
/**
* Hook for managing appear animation with proper cleanup.
*
* @param effects - Element effect properties containing appear animation config
* @param key - Optional key to reset animation (e.g., page slug for re-triggering on navigation)
* @returns Object with animationStyle and onAnimationEnd handler
*
* @example
* const { animationStyle, onAnimationEnd } = useAppearAnimation(effectProperties);
* return (
* <div
* style={{ ...baseStyle, ...animationStyle }}
* onAnimationEnd={onAnimationEnd}
* >
* {content}
* </div>
* );
*/
export function useAppearAnimation(
effects: Partial<ElementEffectProperties>,
key?: string | number,
): UseAppearAnimationResult {
// Track whether animation has completed
const [hasAnimationEnded, setHasAnimationEnded] = useState(false);
// Track the animation name to identify our animation in the event
const animationNameRef = useRef<string | null>(null);
// Reset animation state when key changes (e.g., navigating to new page)
useEffect(() => {
setHasAnimationEnded(false);
}, [key]);
// Build animation style and capture animation name
const animationStyle = (() => {
// If no appear animation or animation already ended, return empty
if (!effects.appearAnimation || hasAnimationEnded) {
animationNameRef.current = null;
return {};
}
const style = buildAppearAnimationStyle(effects);
// Extract animation name from the style for event matching
if (style.animation) {
const match = String(style.animation).match(/^([\w-]+)/);
animationNameRef.current = match ? match[1] : null;
}
return style;
})();
// Handler for animationend event
const onAnimationEnd: AnimationEventHandler<HTMLElement> = useCallback(
(event) => {
// Only handle our animation (ignore child animations)
// Check if the animation name matches our appear animation
const targetAnimationName = animationNameRef.current;
if (
targetAnimationName &&
event.animationName === targetAnimationName &&
event.target === event.currentTarget
) {
setHasAnimationEnded(true);
}
},
[],
);
return {
animationStyle,
onAnimationEnd,
hasAnimationEnded,
};
}

View File

@ -76,7 +76,8 @@ export function useCSVHandling(
responseType: 'blob', responseType: 'blob',
}); });
const contentType = (response.headers['content-type'] as string) || 'text/csv'; const contentType =
(response.headers['content-type'] as string) || 'text/csv';
const blob = new Blob([response.data], { type: contentType }); const blob = new Blob([response.data], { type: contentType });
const link = document.createElement('a'); const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob); link.href = window.URL.createObjectURL(blob);

View File

@ -1,21 +1,23 @@
/** /**
* useElementEffects Hook * useElementEffects Hook
* *
* Manages element interactive effects (hover, focus, active) at runtime. * Manages element interactive effects (hover, focus, active, reveal) at runtime.
* Since CSS pseudo-classes don't work with inline styles, this hook * Since CSS pseudo-classes don't work with inline styles, this hook
* handles state-based style application via JavaScript events. * handles state-based style application via JavaScript events.
*/ */
import { useCallback, useState } from 'react'; import { useCallback, useState, useEffect } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { import {
buildHoverStyle, buildHoverStyle,
buildFocusStyle, buildFocusStyle,
buildActiveStyle, buildActiveStyle,
buildTransitionStyle, buildTransitionStyle,
buildHoverRevealStyle,
hasHoverEffects, hasHoverEffects,
hasFocusEffects, hasFocusEffects,
hasActiveEffects, hasActiveEffects,
hasHoverReveal,
type ElementEffectProperties, type ElementEffectProperties,
} from '../lib/elementEffects'; } from '../lib/elementEffects';
@ -23,6 +25,15 @@ interface ElementEffectState {
isHovered: boolean; isHovered: boolean;
isFocused: boolean; isFocused: boolean;
isActive: boolean; isActive: boolean;
isRevealed: boolean;
isClickPersisted: boolean;
}
interface UseElementEffectsOptions {
/** Key to reset reveal state (e.g., element.id or page slug) */
resetKey?: string | number;
/** Whether appear animation has completed (to coordinate with reveal) */
appearAnimationCompleted?: boolean;
} }
interface UseElementEffectsResult { interface UseElementEffectsResult {
@ -36,17 +47,22 @@ interface UseElementEffectsResult {
onBlur: () => void; onBlur: () => void;
onMouseDown: () => void; onMouseDown: () => void;
onMouseUp: () => void; onMouseUp: () => void;
onTouchStart: () => void;
onTouchEnd: () => void;
}; };
/** Call this in onClick to toggle hover persistence (if enabled) */
onPersistClick: () => void;
} }
/** /**
* Hook for managing element interactive effects. * Hook for managing element interactive effects.
* *
* @param effects - Element effect properties * @param effects - Element effect properties
* @param options - Optional configuration (resetKey, appearAnimationCompleted)
* @returns Object with effectStyle and eventHandlers * @returns Object with effectStyle and eventHandlers
* *
* @example * @example
* const { effectStyle, eventHandlers } = useElementEffects(element); * const { effectStyle, eventHandlers } = useElementEffects(element, { resetKey: element.id });
* return ( * return (
* <div style={{ ...baseStyle, ...effectStyle }} {...eventHandlers}> * <div style={{ ...baseStyle, ...effectStyle }} {...eventHandlers}>
* {content} * {content}
@ -55,16 +71,34 @@ interface UseElementEffectsResult {
*/ */
export function useElementEffects( export function useElementEffects(
effects: Partial<ElementEffectProperties>, effects: Partial<ElementEffectProperties>,
options?: UseElementEffectsOptions,
): UseElementEffectsResult { ): UseElementEffectsResult {
const { resetKey, appearAnimationCompleted = true } = options ?? {};
const [state, setState] = useState<ElementEffectState>({ const [state, setState] = useState<ElementEffectState>({
isHovered: false, isHovered: false,
isFocused: false, isFocused: false,
isActive: false, isActive: false,
isRevealed: false,
isClickPersisted: false,
}); });
// Reset reveal state and click-persisted state when resetKey changes (e.g., page navigation)
useEffect(() => {
setState((prev) => ({
...prev,
isRevealed: false,
isClickPersisted: false,
}));
}, [resetKey]);
const onMouseEnter = useCallback(() => { const onMouseEnter = useCallback(() => {
setState((prev) => ({ ...prev, isHovered: true })); setState((prev) => ({
}, []); ...prev,
isHovered: true,
isRevealed: effects.hoverReveal ? true : prev.isRevealed,
}));
}, [effects.hoverReveal]);
const onMouseLeave = useCallback(() => { const onMouseLeave = useCallback(() => {
setState((prev) => ({ ...prev, isHovered: false, isActive: false })); setState((prev) => ({ ...prev, isHovered: false, isActive: false }));
@ -86,22 +120,74 @@ export function useElementEffects(
setState((prev) => ({ ...prev, isActive: false })); setState((prev) => ({ ...prev, isActive: false }));
}, []); }, []);
// Touch handlers for mobile devices
const onTouchStart = useCallback(() => {
setState((prev) => ({
...prev,
isHovered: true,
isRevealed: effects.hoverReveal ? true : prev.isRevealed,
}));
}, [effects.hoverReveal]);
const onTouchEnd = useCallback(() => {
// For hover reveal, keep visibility on touch (like persist mode)
if (!effects.hoverReveal) {
setState((prev) => ({ ...prev, isHovered: false, isActive: false }));
}
}, [effects.hoverReveal]);
// Click handler for toggling hover persistence
const onClick = useCallback(() => {
if (effects.hoverPersistOnClick) {
// Toggle: first click persists, second click removes, third persists, etc.
setState((prev) => ({
...prev,
isClickPersisted: !prev.isClickPersisted,
}));
}
}, [effects.hoverPersistOnClick]);
// Build effective style based on current state // Build effective style based on current state
// Priority: active > focus > hover > base // Priority: active > focus > hover reveal > hover > base
let effectStyle: CSSProperties = {}; let effectStyle: CSSProperties = {};
// Add transition for smooth effect changes // Add transition for smooth effect changes
if ( if (
hasHoverEffects(effects) || hasHoverEffects(effects) ||
hasFocusEffects(effects) || hasFocusEffects(effects) ||
hasActiveEffects(effects) hasActiveEffects(effects) ||
hasHoverReveal(effects)
) { ) {
effectStyle = { ...effectStyle, ...buildTransitionStyle(effects) }; effectStyle = { ...effectStyle, ...buildTransitionStyle(effects) };
} }
// Apply hover effects when hovered (but not active) // Apply hover reveal style (controls opacity for reveal elements)
if (state.isHovered && !state.isActive && hasHoverEffects(effects)) { // Only apply initial opacity AFTER appear animation completes to avoid conflict
effectStyle = { ...effectStyle, ...buildHoverStyle(effects) }; if (hasHoverReveal(effects) && appearAnimationCompleted) {
// With persist OR click-persisted: stays visible
// Without: only visible while hovering
const shouldShow =
effects.hoverRevealPersist || state.isClickPersisted
? state.isRevealed || state.isClickPersisted
: state.isHovered;
effectStyle = {
...effectStyle,
...buildHoverRevealStyle(effects, shouldShow),
};
}
// Apply hover effects when hovered OR click-persisted (but not active)
// Skip opacity when hoverReveal is active (reveal controls it)
const shouldApplyHover = state.isHovered || state.isClickPersisted;
if (shouldApplyHover && !state.isActive && hasHoverEffects(effects)) {
const hoverStyle = buildHoverStyle(effects);
if (hasHoverReveal(effects)) {
// Exclude opacity from hover style when reveal is active
const { opacity, ...restHoverStyle } = hoverStyle;
effectStyle = { ...effectStyle, ...restHoverStyle };
} else {
effectStyle = { ...effectStyle, ...hoverStyle };
}
} }
// Apply focus effects when focused // Apply focus effects when focused
@ -123,6 +209,9 @@ export function useElementEffects(
onBlur, onBlur,
onMouseDown, onMouseDown,
onMouseUp, onMouseUp,
onTouchStart,
onTouchEnd,
}, },
onPersistClick: onClick,
}; };
} }

View File

@ -131,7 +131,8 @@ export function useNetworkAware(): UseNetworkAwareResult {
if (networkInfo.effectiveType === 'slow-2g') return false; if (networkInfo.effectiveType === 'slow-2g') return false;
if (networkInfo.effectiveType === '2g') return false; if (networkInfo.effectiveType === '2g') return false;
if (networkInfo.effectiveType === '3g') return false; if (networkInfo.effectiveType === '3g') return false;
if (networkInfo.downlink !== undefined && networkInfo.downlink < 2) return false; if (networkInfo.downlink !== undefined && networkInfo.downlink < 2)
return false;
if (networkInfo.rtt !== undefined && networkInfo.rtt > 500) return false; if (networkInfo.rtt !== undefined && networkInfo.rtt > 500) return false;
return true; return true;
}, [networkInfo]); }, [networkInfo]);

View File

@ -348,7 +348,10 @@ export function usePreloadOrchestrator(
const currentPageElements = elements.filter( const currentPageElements = elements.filter(
(el) => el.pageId === currentPageId, (el) => el.pageId === currentPageId,
); );
const elementAssets = extractElementAssets(currentPageElements, currentPageId); const elementAssets = extractElementAssets(
currentPageElements,
currentPageId,
);
// Collect storage paths for presigned URL batch request // Collect storage paths for presigned URL batch request
const storagePaths: string[] = []; const storagePaths: string[] = [];
@ -513,7 +516,8 @@ export function usePreloadOrchestrator(
const job = createDownloadJob( const job = createDownloadJob(
`elem-img-${asset.storageKey}`, `elem-img-${asset.storageKey}`,
asset.storageKey, asset.storageKey,
PRELOAD_CONFIG.priority.currentPage + PRELOAD_CONFIG.priority.assetType.image, PRELOAD_CONFIG.priority.currentPage +
PRELOAD_CONFIG.priority.assetType.image,
'image', 'image',
); );
if (job) { if (job) {
@ -593,7 +597,8 @@ export function usePreloadOrchestrator(
`trans-rev-${link.from_pageId}-${link.to_pageId}`, `trans-rev-${link.from_pageId}-${link.to_pageId}`,
reverseVideoUrl, reverseVideoUrl,
PRELOAD_CONFIG.priority.currentPage + PRELOAD_CONFIG.priority.currentPage +
PRELOAD_CONFIG.priority.assetType.transition - 10, PRELOAD_CONFIG.priority.assetType.transition -
10,
'transition', 'transition',
); );
if (job) { if (job) {
@ -628,7 +633,14 @@ export function usePreloadOrchestrator(
} else { } else {
addAssetsToQueue(); addAssetsToQueue();
} }
}, [enabled, currentPageId, networkInfo.isOnline, elements, pages, pageLinks]); }, [
enabled,
currentPageId,
networkInfo.isOnline,
elements,
pages,
pageLinks,
]);
const isCurrentPageReady = const isCurrentPageReady =
currentPhase === 'phase2_transitions' || currentPhase === 'complete'; currentPhase === 'phase2_transitions' || currentPhase === 'complete';

View File

@ -368,7 +368,11 @@ export function useTransitionPlayback(
if (!Number.isFinite(durationSec) || durationSec <= 0) return; if (!Number.isFinite(durationSec) || durationSec <= 0) return;
const finishBeforeEndMs = getFinishBeforeEndMs(); const finishBeforeEndMs = getFinishBeforeEndMs();
const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs); const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs);
setTimer('finishByDuration', () => finishPlayback('duration-timer'), finishMs); setTimer(
'finishByDuration',
() => finishPlayback('duration-timer'),
finishMs,
);
}; };
const attemptPlay = () => { const attemptPlay = () => {
@ -525,12 +529,20 @@ export function useTransitionPlayback(
const timeSinceProgress = Date.now() - lastProgressTimeRef.current; const timeSinceProgress = Date.now() - lastProgressTimeRef.current;
if (!video.paused && !isWaitingForDataRef.current) { if (!video.paused && !isWaitingForDataRef.current) {
setTimer('progressCheck', checkProgress, progressTimeout.checkIntervalMs); setTimer(
'progressCheck',
checkProgress,
progressTimeout.checkIntervalMs,
);
return; return;
} }
if (timeSinceProgress < actualNoProgressMs) { if (timeSinceProgress < actualNoProgressMs) {
setTimer('progressCheck', checkProgress, progressTimeout.checkIntervalMs); setTimer(
'progressCheck',
checkProgress,
progressTimeout.checkIntervalMs,
);
return; return;
} }

View File

@ -155,7 +155,8 @@ export function useVideoBlobUrl({
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) { if (!cancelled) {
const resolveError = err instanceof Error ? err : new Error(String(err)); const resolveError =
err instanceof Error ? err : new Error(String(err));
setError(resolveError); setError(resolveError);
setIsResolving(false); setIsResolving(false);
onErrorRef.current?.(resolveError); onErrorRef.current?.(resolveError);

View File

@ -7,7 +7,13 @@
* - Progress-based timeout detection * - Progress-based timeout detection
*/ */
import { useState, useRef, useCallback, useEffect, type RefObject } from 'react'; import {
useState,
useRef,
useCallback,
useEffect,
type RefObject,
} from 'react';
import { logger } from '../../lib/logger'; import { logger } from '../../lib/logger';
import { TRANSITION_CONFIG } from '../../config/transition.config'; import { TRANSITION_CONFIG } from '../../config/transition.config';
@ -88,7 +94,8 @@ export function useVideoBufferingState({
(isMobile (isMobile
? progressTimeout.noProgressMs * progressTimeout.mobileMultiplier ? progressTimeout.noProgressMs * progressTimeout.mobileMultiplier
: progressTimeout.noProgressMs); : progressTimeout.noProgressMs);
const actualCheckIntervalMs = checkIntervalMs ?? progressTimeout.checkIntervalMs; const actualCheckIntervalMs =
checkIntervalMs ?? progressTimeout.checkIntervalMs;
const stopProgressMonitor = useCallback(() => { const stopProgressMonitor = useCallback(() => {
if (progressTimeoutRef.current) { if (progressTimeoutRef.current) {
@ -117,13 +124,19 @@ export function useVideoBufferingState({
// If playing normally (not waiting), continue monitoring // If playing normally (not waiting), continue monitoring
if (!video.paused && !isWaitingForDataRef.current) { if (!video.paused && !isWaitingForDataRef.current) {
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs); progressTimeoutRef.current = setTimeout(
checkProgress,
actualCheckIntervalMs,
);
return; return;
} }
// If waiting but received data recently, keep waiting // If waiting but received data recently, keep waiting
if (timeSinceProgress < actualNoProgressMs) { if (timeSinceProgress < actualNoProgressMs) {
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs); progressTimeoutRef.current = setTimeout(
checkProgress,
actualCheckIntervalMs,
);
return; return;
} }
@ -137,7 +150,10 @@ export function useVideoBufferingState({
onProgressTimeoutRef.current?.(); onProgressTimeoutRef.current?.();
}; };
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs); progressTimeoutRef.current = setTimeout(
checkProgress,
actualCheckIntervalMs,
);
}, [videoRef, actualNoProgressMs, actualCheckIntervalMs]); }, [videoRef, actualNoProgressMs, actualCheckIntervalMs]);
const reset = useCallback(() => { const reset = useCallback(() => {

View File

@ -95,7 +95,8 @@ export function useVideoErrorRecovery({
if (!error) return false; if (!error) return false;
const errorCode = error.code; const errorCode = error.code;
const errorMessage = (error as MediaError & { message?: string }).message || ''; const errorMessage =
(error as MediaError & { message?: string }).message || '';
logger.error('useVideoErrorRecovery: Video error', { logger.error('useVideoErrorRecovery: Video error', {
code: errorCode, code: errorCode,
@ -112,10 +113,13 @@ export function useVideoErrorRecovery({
decodeRetryCountRef.current < actualMaxDecodeRetries decodeRetryCountRef.current < actualMaxDecodeRetries
) { ) {
decodeRetryCountRef.current++; decodeRetryCountRef.current++;
logger.info('useVideoErrorRecovery: Safari decode error, attempting reload', { logger.info(
attempt: decodeRetryCountRef.current, 'useVideoErrorRecovery: Safari decode error, attempting reload',
maxAttempts: actualMaxDecodeRetries, {
}); attempt: decodeRetryCountRef.current,
maxAttempts: actualMaxDecodeRetries,
},
);
video.load(); video.load();
video.play().catch(() => { video.play().catch(() => {
/* ignore play errors during recovery */ /* ignore play errors during recovery */
@ -133,9 +137,12 @@ export function useVideoErrorRecovery({
!didTryProxyRef.current !didTryProxyRef.current
) { ) {
didTryProxyRef.current = true; didTryProxyRef.current = true;
logger.info('useVideoErrorRecovery: Presigned URL failed, retrying with proxy', { logger.info(
storageKey: currentStorageKey.slice(-40), 'useVideoErrorRecovery: Presigned URL failed, retrying with proxy',
}); {
storageKey: currentStorageKey.slice(-40),
},
);
markPresignedUrlFailed(currentStorageKey); markPresignedUrlFailed(currentStorageKey);
const proxyUrl = buildProxyUrl(currentStorageKey); const proxyUrl = buildProxyUrl(currentStorageKey);
onRetryWithSourceRef.current?.(proxyUrl); onRetryWithSourceRef.current?.(proxyUrl);

View File

@ -6,7 +6,13 @@
* - requestAnimationFrame fallback (older browsers) * - requestAnimationFrame fallback (older browsers)
*/ */
import { useState, useRef, useCallback, useEffect, type RefObject } from 'react'; import {
useState,
useRef,
useCallback,
useEffect,
type RefObject,
} from 'react';
import { logger } from '../../lib/logger'; import { logger } from '../../lib/logger';
import { scheduleAfterPaint } from '../../lib/browserUtils'; import { scheduleAfterPaint } from '../../lib/browserUtils';
@ -60,8 +66,11 @@ export function useVideoFirstFrame({
if (callbackIdRef.current !== null) { if (callbackIdRef.current !== null) {
const video = videoRef.current; const video = videoRef.current;
if (video && 'cancelVideoFrameCallback' in video) { if (video && 'cancelVideoFrameCallback' in video) {
(video as HTMLVideoElement & { cancelVideoFrameCallback: (id: number) => void }) (
.cancelVideoFrameCallback(callbackIdRef.current); video as HTMLVideoElement & {
cancelVideoFrameCallback: (id: number) => void;
}
).cancelVideoFrameCallback(callbackIdRef.current);
} }
callbackIdRef.current = null; callbackIdRef.current = null;
} }
@ -77,11 +86,16 @@ export function useVideoFirstFrame({
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+) // Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+)
if ('requestVideoFrameCallback' in video) { if ('requestVideoFrameCallback' in video) {
const rvfc = (video as HTMLVideoElement & { const rvfc = (
requestVideoFrameCallback: ( video as HTMLVideoElement & {
callback: (now: number, metadata: VideoFrameCallbackMetadata) => void requestVideoFrameCallback: (
) => number; callback: (
}).requestVideoFrameCallback.bind(video); now: number,
metadata: VideoFrameCallbackMetadata,
) => void,
) => number;
}
).requestVideoFrameCallback.bind(video);
// First callback: frame is composited, safe to show overlay // First callback: frame is composited, safe to show overlay
callbackIdRef.current = rvfc((_now, _metadata) => { callbackIdRef.current = rvfc((_now, _metadata) => {
@ -99,7 +113,9 @@ export function useVideoFirstFrame({
if (!didFireRef.current) { if (!didFireRef.current) {
didFireRef.current = true; didFireRef.current = true;
setIsFirstFramePainted(true); setIsFirstFramePainted(true);
logger.info('useVideoFirstFrame: First frame painted (rAF fallback)'); logger.info(
'useVideoFirstFrame: First frame painted (rAF fallback)',
);
onFirstFrameRef.current?.(); onFirstFrameRef.current?.();
} }
}); });

View File

@ -6,7 +6,13 @@
* and background video playback. * and background video playback.
*/ */
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; import {
useCallback,
useEffect,
useRef,
useState,
type RefObject,
} from 'react';
import { logger } from '../../lib/logger'; import { logger } from '../../lib/logger';
import { useVideoBlobUrl, type PreloadCacheProvider } from './useVideoBlobUrl'; import { useVideoBlobUrl, type PreloadCacheProvider } from './useVideoBlobUrl';
import { useVideoBufferingState } from './useVideoBufferingState'; import { useVideoBufferingState } from './useVideoBufferingState';
@ -265,7 +271,9 @@ export function useVideoPlaybackCore({
'playbackStart', 'playbackStart',
() => { () => {
if (!didStartPlaybackRef.current) { if (!didStartPlaybackRef.current) {
logger.warn('useVideoPlaybackCore: Playback start timeout, retrying'); logger.warn(
'useVideoPlaybackCore: Playback start timeout, retrying',
);
play(); play();
} }
}, },

View File

@ -11,7 +11,13 @@
* not for transition or background videos which have their own hooks. * not for transition or background videos which have their own hooks.
*/ */
import { useRef, useCallback, useEffect, useState, type RefObject } from 'react'; import {
useRef,
useCallback,
useEffect,
useState,
type RefObject,
} from 'react';
import { logger } from '../../lib/logger'; import { logger } from '../../lib/logger';
import { useVideoBlobUrl, type PreloadCacheProvider } from './useVideoBlobUrl'; import { useVideoBlobUrl, type PreloadCacheProvider } from './useVideoBlobUrl';
import { useVideoErrorRecovery } from './useVideoErrorRecovery'; import { useVideoErrorRecovery } from './useVideoErrorRecovery';

View File

@ -39,7 +39,9 @@ export interface UseVideoTimeoutsResult {
* clearTimer('watchdog'); * clearTimer('watchdog');
*/ */
export function useVideoTimeouts(): UseVideoTimeoutsResult { export function useVideoTimeouts(): UseVideoTimeoutsResult {
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map()); const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
const clearTimer = useCallback((name: string) => { const clearTimer = useCallback((name: string) => {
const timer = timersRef.current.get(name); const timer = timersRef.current.get(name);

View File

@ -37,6 +37,13 @@ export const ELEMENT_EFFECT_PROPS = [
'activeScale', 'activeScale',
'activeOpacity', 'activeOpacity',
'activeBackgroundColor', 'activeBackgroundColor',
'hoverReveal',
'hoverRevealInitialOpacity',
'hoverRevealTargetOpacity',
'hoverRevealDuration',
'hoverRevealDelay',
'hoverRevealPersist',
'hoverPersistOnClick',
] as const; ] as const;
/** /**

View File

@ -69,6 +69,15 @@ export interface ElementEffectProperties {
activeScale?: string; activeScale?: string;
activeOpacity?: string; activeOpacity?: string;
activeBackgroundColor?: string; activeBackgroundColor?: string;
// Hover Reveal effects
hoverReveal?: boolean;
hoverRevealInitialOpacity?: string;
hoverRevealTargetOpacity?: string;
hoverRevealDuration?: string;
hoverRevealDelay?: string;
hoverRevealPersist?: boolean;
// Persist hover effects after click (applies to all hover effects)
hoverPersistOnClick?: boolean;
} }
/** /**
@ -91,6 +100,13 @@ export const EFFECT_PROPS = [
'activeScale', 'activeScale',
'activeOpacity', 'activeOpacity',
'activeBackgroundColor', 'activeBackgroundColor',
'hoverReveal',
'hoverRevealInitialOpacity',
'hoverRevealTargetOpacity',
'hoverRevealDuration',
'hoverRevealDelay',
'hoverRevealPersist',
'hoverPersistOnClick',
] as const; ] as const;
export type EffectPropName = (typeof EFFECT_PROPS)[number]; export type EffectPropName = (typeof EFFECT_PROPS)[number];
@ -287,6 +303,54 @@ export function hasAnyEffects(
Boolean(effects.appearAnimation) || Boolean(effects.appearAnimation) ||
hasHoverEffects(effects) || hasHoverEffects(effects) ||
hasFocusEffects(effects) || hasFocusEffects(effects) ||
hasActiveEffects(effects) hasActiveEffects(effects) ||
hasHoverReveal(effects)
); );
} }
/**
* Check if element has hover reveal enabled.
*/
export function hasHoverReveal(
effects: Partial<ElementEffectProperties>,
): boolean {
return Boolean(effects.hoverReveal);
}
/**
* Build hover reveal style based on current reveal state.
*/
export function buildHoverRevealStyle(
effects: Partial<ElementEffectProperties>,
isRevealed: boolean,
): CSSProperties {
if (!effects.hoverReveal) return {};
const initialOpacity = parseFloat(effects.hoverRevealInitialOpacity || '0');
const targetOpacity = parseFloat(effects.hoverRevealTargetOpacity || '1');
const duration = normalizeDuration(effects.hoverRevealDuration) || '0.3s';
const delay = normalizeDuration(effects.hoverRevealDelay) || '0s';
return {
opacity: isRevealed ? targetOpacity : initialOpacity,
transition: `opacity ${duration} ease ${delay}`,
};
}
/**
* Extract effect properties from element to avoid duplication
* in RuntimeElement and CanvasElement.
* Uses EFFECT_PROPS array as source of truth.
*/
export function extractEffectProperties(
element: Record<string, unknown>,
): Partial<ElementEffectProperties> {
const props: Partial<ElementEffectProperties> = {};
for (const key of EFFECT_PROPS) {
const value = element[key];
if (value !== undefined) {
(props as Record<string, unknown>)[key] = value;
}
}
return props;
}

View File

@ -531,7 +531,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
[navNavigateToPage, applyPageSelection], [navNavigateToPage, applyPageSelection],
); );
const { isBuffering: isTransitionBuffering, isVideoReady: isTransitionVideoReady } = useTransitionPlayback({ const {
isBuffering: isTransitionBuffering,
isVideoReady: isTransitionVideoReady,
} = useTransitionPlayback({
videoRef: transitionVideoRef, videoRef: transitionVideoRef,
transition: transitionPreview transition: transitionPreview
? { ? {
@ -1298,7 +1301,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Note: Background ready state is reset atomically by navigateToPage/switchToPage // Note: Background ready state is reset atomically by navigateToPage/switchToPage
// Check if transition can be played using shared helper // Check if transition can be played using shared helper
const canPlayTransition = hasPlayableTransition(transitionSource, direction); const canPlayTransition = hasPlayableTransition(
transitionSource,
direction,
);
// Check if video is already cached (use video even on slow network if cached) // Check if video is already cached (use video even on slow network if cached)
const transitionUrl = transitionSource.transitionVideoUrl; const transitionUrl = transitionSource.transitionVideoUrl;
@ -1307,7 +1313,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Use video if: has playable transition AND (cached OR good network) // Use video if: has playable transition AND (cached OR good network)
const useVideoTransition = const useVideoTransition =
canPlayTransition && (isTransitionCached || shouldUseVideoTransitions); canPlayTransition &&
(isTransitionCached || shouldUseVideoTransitions);
if (!useVideoTransition) { if (!useVideoTransition) {
closeTransitionPreview(); closeTransitionPreview();
@ -1926,7 +1933,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
pageTransitionSettings={transitionSettings} pageTransitionSettings={transitionSettings}
preloadCache={{ preloadCache={{
getReadyBlob: preloadOrchestrator.getReadyBlob, getReadyBlob: preloadOrchestrator.getReadyBlob,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, getCachedBlobUrl:
preloadOrchestrator.getCachedBlobUrl,
}} }}
/> />
); );

View File

@ -184,7 +184,16 @@ const ElementTypeDefaultDetailsPage = () => {
// Handler for effects section changes // Handler for effects section changes
const handleEffectChange = useCallback( const handleEffectChange = useCallback(
(prop: string, value: string) => { (prop: string, value: string) => {
setField(prop as keyof typeof form.state, value); // Convert string 'true'/'false' to boolean for boolean fields
if (
prop === 'hoverReveal' ||
prop === 'hoverRevealPersist' ||
prop === 'hoverPersistOnClick'
) {
setField(prop as keyof typeof form.state, (value === 'true') as never);
} else {
setField(prop as keyof typeof form.state, value as never);
}
}, },
[setField], [setField],
); );

View File

@ -311,7 +311,16 @@ const ProjectElementDefaultDetailsPage = () => {
// Handler for effects section changes // Handler for effects section changes
const handleEffectChange = useCallback( const handleEffectChange = useCallback(
(prop: string, value: string) => { (prop: string, value: string) => {
setField(prop as keyof typeof form.state, value); // Convert string 'true'/'false' to boolean for boolean fields
if (
prop === 'hoverReveal' ||
prop === 'hoverRevealPersist' ||
prop === 'hoverPersistOnClick'
) {
setField(prop as keyof typeof form.state, (value === 'true') as never);
} else {
setField(prop as keyof typeof form.state, value as never);
}
}, },
[setField], [setField],
); );