Revert to version c88f5d2

This commit is contained in:
Flatlogic Bot 2026-05-28 07:19:36 +00:00
parent e0a848b50c
commit 4bf22f8461
34 changed files with 938 additions and 171 deletions

View File

@ -20,7 +20,9 @@ passport.use(
async (req, token, done) => {
try {
// 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) {
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;
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('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);
}
@ -484,12 +490,22 @@ const downloadFile = async (req, res) => {
const rangeHeader = req.headers.range;
if (rangeHeader) {
// 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;
if (!totalSize) {
log.warn({ privateUrl }, 'Cannot determine file size for range request');
return res.status(500).send(createErrorResponse('Cannot determine file size', 'SIZE_UNKNOWN'));
log.warn(
{ 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);
@ -510,12 +526,18 @@ const downloadFile = async (req, res) => {
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`);
res.setHeader('Content-Length', chunkSize);
if (rangeResult.contentType) res.setHeader('Content-Type', rangeResult.contentType);
res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`);
if (rangeResult.contentType)
res.setHeader('Content-Type', rangeResult.contentType);
res.setHeader(
'Cache-Control',
`public, max-age=${config.s3CacheMaxAge}`,
);
if (typeof rangeResult.body.pipe === 'function') {
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();
return res.send(Buffer.from(bytes));
} else {
@ -638,7 +660,9 @@ const downloadFile = async (req, res) => {
const localFilePath = path.join(config.uploadDir, privateUrl);
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);

View File

@ -725,7 +725,11 @@ class TourPagesService extends BaseService {
* @returns {Promise<Object>} Status object with ready keys and their reversed URLs
*/
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 };
}

File diff suppressed because one or more lines are too long

View File

@ -10,11 +10,11 @@
import React from 'react';
import UiElementRenderer from '../UiElements/UiElementRenderer';
import { useElementEffects } from '../../hooks/useElementEffects';
import { useAppearAnimation } from '../../hooks/useAppearAnimation';
import {
buildTransitionStyle,
buildAppearAnimationStyle,
hasAnyEffects,
type ElementEffectProperties,
extractEffectProperties,
} from '../../lib/elementEffects';
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition';
@ -57,31 +57,38 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
pageTransitionSettings,
preloadCache,
}) => {
// Extract effect properties from element
const effectProperties: Partial<ElementEffectProperties> = {
appearAnimation: element.appearAnimation,
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,
};
// Extract effect properties from element using helper
const effectProperties = extractEffectProperties(
element as unknown as Record<string, unknown>,
);
// Use appear animation hook (removes animation after completion to unlock properties)
const { animationStyle, onAnimationEnd, hasAnimationEnded } =
useAppearAnimation(
effectProperties,
element.id, // Reset animation when element changes
);
// Use effects hook - disabled in edit mode to avoid interfering with dragging
const { effectStyle, eventHandlers } = useElementEffects(
const { effectStyle, eventHandlers, onPersistClick } = useElementEffects(
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%)
const clamp = (value: number, min: number, max: number) =>
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
const handleKeyDown = (event: React.KeyboardEvent) => {
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 (
<div
role='button'
tabIndex={0}
data-constructor-element-id={element.id}
className='absolute cursor-pointer'
style={positionStyle}
style={combinedStyle}
onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={onClick}
onClick={handleClick}
onKeyDown={handleKeyDown}
onAnimationEnd={
effectProperties.appearAnimation ? onAnimationEnd : undefined
}
{...(!isEditMode ? eventHandlers : {})}
>
<UiElementRenderer
element={element}
resolveUrl={resolveUrl}
isSelected={isSelected}
isEditMode={isEditMode}
onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
{content}
</div>
);
};

View File

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

View File

@ -764,6 +764,21 @@ export function ElementEditorPanel({
activeOpacity: selectedElement.activeOpacity || '',
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)
slideTransitionType:
selectedElement.type === 'gallery'
@ -858,6 +873,15 @@ export function ElementEditorPanel({
value || undefined,
});
}
} else if (
prop === 'hoverReveal' ||
prop === 'hoverRevealPersist' ||
prop === 'hoverPersistOnClick'
) {
// Boolean properties - convert 'true' string to boolean
updateSelectedElement({
[prop]: value === 'true',
});
} else {
// Standard effect properties
updateSelectedElement({

View File

@ -114,6 +114,98 @@ const EffectsSettingsSection: React.FC<EffectsSettingsSectionProps> = ({
placeholder='e.g. 0.2'
/>
</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>

View File

@ -152,6 +152,108 @@ const EffectsSettingsSectionCompact: React.FC<
placeholder='0.2'
/>
</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>

View File

@ -53,6 +53,15 @@ export interface EffectsSettingsFormValues {
activeScale?: string;
activeOpacity?: 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)
// These override page transition settings for this element's slides
slideTransitionType?: string;

View File

@ -87,6 +87,16 @@ interface FormState {
activeOpacity: 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
iconUrl: string;
navLabel: string;
@ -182,6 +192,14 @@ const initialState: FormState = {
activeScale: '',
activeOpacity: '',
activeBackgroundColor: '',
// Hover Reveal settings
hoverReveal: false,
hoverRevealInitialOpacity: '',
hoverRevealTargetOpacity: '',
hoverRevealDuration: '',
hoverRevealDelay: '',
hoverRevealPersist: false,
hoverPersistOnClick: false,
iconUrl: '',
navLabel: '',
navLabelFontFamily: '',
@ -284,6 +302,18 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
activeScale: String(settings.activeScale || ''),
activeOpacity: String(settings.activeOpacity || ''),
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),
appearDurationSec:
settings.appearDurationSec === null ||
@ -422,6 +452,14 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
activeScale: state.activeScale,
activeOpacity: state.activeOpacity,
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]);
@ -624,6 +662,30 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
if (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
if (isNavigationType) {
settings.iconUrl = state.iconUrl.trim();

View File

@ -9,11 +9,11 @@
import React from 'react';
import UiElementRenderer from './UiElements/UiElementRenderer';
import { useElementEffects } from '../hooks/useElementEffects';
import { useAppearAnimation } from '../hooks/useAppearAnimation';
import {
buildTransitionStyle,
buildAppearAnimationStyle,
hasAnyEffects,
type ElementEffectProperties,
extractEffectProperties,
} from '../lib/elementEffects';
import type { CanvasElement } from '../types/constructor';
import type { ResolvedTransitionSettings } from '../types/transition';
@ -52,28 +52,33 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
const yPercent = clamp(element.yPercent ?? 50, 0, 100);
const rotation = element.rotation ?? 0;
// Extract effect properties from element
const effectProperties: Partial<ElementEffectProperties> = {
appearAnimation: element.appearAnimation,
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,
};
// Extract effect properties from element using helper
const effectProperties = extractEffectProperties(
element as unknown as Record<string, unknown>,
);
// Use effects hook for interactive states
const { effectStyle, eventHandlers } = useElementEffects(effectProperties);
// Use appear animation hook (removes animation after completion to unlock properties)
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
let positionStyle: React.CSSProperties = {
@ -99,28 +104,58 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
positionStyle = { ...positionStyle, ...transitionStyle };
}
// Add appear animation if configured
if (effectProperties.appearAnimation) {
const animationStyle = buildAppearAnimationStyle(effectProperties);
positionStyle = { ...positionStyle, ...animationStyle };
// Check if appear animation uses transform (slide/scale need separate wrapper)
const needsAnimationWrapper =
effectProperties.appearAnimation &&
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 (
<div
className='absolute cursor-pointer'
style={positionStyle}
onClick={onClick}
style={combinedStyle}
onClick={handleClick}
tabIndex={0}
onAnimationEnd={
effectProperties.appearAnimation ? onAnimationEnd : undefined
}
{...eventHandlers}
>
<UiElementRenderer
element={element}
resolveUrl={resolveUrl}
onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
{content}
</div>
);
};

View File

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

View File

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

View File

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

View File

@ -290,11 +290,9 @@
@keyframes element-fade-in {
from {
opacity: 0;
transform: translate3d(0, 0, 0);
}
to {
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',
});
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 link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);

View File

@ -1,21 +1,23 @@
/**
* 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
* 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 {
buildHoverStyle,
buildFocusStyle,
buildActiveStyle,
buildTransitionStyle,
buildHoverRevealStyle,
hasHoverEffects,
hasFocusEffects,
hasActiveEffects,
hasHoverReveal,
type ElementEffectProperties,
} from '../lib/elementEffects';
@ -23,6 +25,15 @@ interface ElementEffectState {
isHovered: boolean;
isFocused: 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 {
@ -36,17 +47,22 @@ interface UseElementEffectsResult {
onBlur: () => void;
onMouseDown: () => 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.
*
* @param effects - Element effect properties
* @param options - Optional configuration (resetKey, appearAnimationCompleted)
* @returns Object with effectStyle and eventHandlers
*
* @example
* const { effectStyle, eventHandlers } = useElementEffects(element);
* const { effectStyle, eventHandlers } = useElementEffects(element, { resetKey: element.id });
* return (
* <div style={{ ...baseStyle, ...effectStyle }} {...eventHandlers}>
* {content}
@ -55,16 +71,34 @@ interface UseElementEffectsResult {
*/
export function useElementEffects(
effects: Partial<ElementEffectProperties>,
options?: UseElementEffectsOptions,
): UseElementEffectsResult {
const { resetKey, appearAnimationCompleted = true } = options ?? {};
const [state, setState] = useState<ElementEffectState>({
isHovered: false,
isFocused: 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(() => {
setState((prev) => ({ ...prev, isHovered: true }));
}, []);
setState((prev) => ({
...prev,
isHovered: true,
isRevealed: effects.hoverReveal ? true : prev.isRevealed,
}));
}, [effects.hoverReveal]);
const onMouseLeave = useCallback(() => {
setState((prev) => ({ ...prev, isHovered: false, isActive: false }));
@ -86,22 +120,74 @@ export function useElementEffects(
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
// Priority: active > focus > hover > base
// Priority: active > focus > hover reveal > hover > base
let effectStyle: CSSProperties = {};
// Add transition for smooth effect changes
if (
hasHoverEffects(effects) ||
hasFocusEffects(effects) ||
hasActiveEffects(effects)
hasActiveEffects(effects) ||
hasHoverReveal(effects)
) {
effectStyle = { ...effectStyle, ...buildTransitionStyle(effects) };
}
// Apply hover effects when hovered (but not active)
if (state.isHovered && !state.isActive && hasHoverEffects(effects)) {
effectStyle = { ...effectStyle, ...buildHoverStyle(effects) };
// Apply hover reveal style (controls opacity for reveal elements)
// Only apply initial opacity AFTER appear animation completes to avoid conflict
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
@ -123,6 +209,9 @@ export function useElementEffects(
onBlur,
onMouseDown,
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 === '2g') 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;
return true;
}, [networkInfo]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,13 @@
* 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 { useVideoBlobUrl, type PreloadCacheProvider } from './useVideoBlobUrl';
import { useVideoBufferingState } from './useVideoBufferingState';
@ -265,7 +271,9 @@ export function useVideoPlaybackCore({
'playbackStart',
() => {
if (!didStartPlaybackRef.current) {
logger.warn('useVideoPlaybackCore: Playback start timeout, retrying');
logger.warn(
'useVideoPlaybackCore: Playback start timeout, retrying',
);
play();
}
},

View File

@ -11,7 +11,13 @@
* 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 { useVideoBlobUrl, type PreloadCacheProvider } from './useVideoBlobUrl';
import { useVideoErrorRecovery } from './useVideoErrorRecovery';

View File

@ -39,7 +39,9 @@ export interface UseVideoTimeoutsResult {
* clearTimer('watchdog');
*/
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 timer = timersRef.current.get(name);

View File

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

View File

@ -69,6 +69,15 @@ export interface ElementEffectProperties {
activeScale?: string;
activeOpacity?: 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',
'activeOpacity',
'activeBackgroundColor',
'hoverReveal',
'hoverRevealInitialOpacity',
'hoverRevealTargetOpacity',
'hoverRevealDuration',
'hoverRevealDelay',
'hoverRevealPersist',
'hoverPersistOnClick',
] as const;
export type EffectPropName = (typeof EFFECT_PROPS)[number];
@ -287,6 +303,54 @@ export function hasAnyEffects(
Boolean(effects.appearAnimation) ||
hasHoverEffects(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],
);
const { isBuffering: isTransitionBuffering, isVideoReady: isTransitionVideoReady } = useTransitionPlayback({
const {
isBuffering: isTransitionBuffering,
isVideoReady: isTransitionVideoReady,
} = useTransitionPlayback({
videoRef: transitionVideoRef,
transition: transitionPreview
? {
@ -1298,7 +1301,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Note: Background ready state is reset atomically by navigateToPage/switchToPage
// 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)
const transitionUrl = transitionSource.transitionVideoUrl;
@ -1307,7 +1313,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Use video if: has playable transition AND (cached OR good network)
const useVideoTransition =
canPlayTransition && (isTransitionCached || shouldUseVideoTransitions);
canPlayTransition &&
(isTransitionCached || shouldUseVideoTransitions);
if (!useVideoTransition) {
closeTransitionPreview();
@ -1926,7 +1933,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
pageTransitionSettings={transitionSettings}
preloadCache={{
getReadyBlob: preloadOrchestrator.getReadyBlob,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
getCachedBlobUrl:
preloadOrchestrator.getCachedBlobUrl,
}}
/>
);

View File

@ -184,7 +184,16 @@ const ElementTypeDefaultDetailsPage = () => {
// Handler for effects section changes
const handleEffectChange = useCallback(
(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],
);

View File

@ -311,7 +311,16 @@ const ProjectElementDefaultDetailsPage = () => {
// Handler for effects section changes
const handleEffectChange = useCallback(
(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],
);