Revert to version c88f5d2
This commit is contained in:
parent
92e70775c1
commit
e0f32356ba
@ -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`));
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`}>
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
frontend/src/hooks/useAppearAnimation.ts
Normal file
108
frontend/src/hooks/useAppearAnimation.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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?.();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user