fixed smooth transition issue and effects

This commit is contained in:
Dmitri 2026-03-31 17:42:51 +04:00
parent a454109b79
commit d1de154b35
11 changed files with 239 additions and 39 deletions

File diff suppressed because one or more lines are too long

View File

@ -3,10 +3,19 @@
*
* Individual element rendered on the constructor canvas.
* Handles positioning and interaction, delegates styling to UiElementRenderer.
* Supports WYSIWYG effects: appear animations always shown, interactive
* effects (hover/focus/active) only in preview mode (not edit mode).
*/
import React from 'react';
import UiElementRenderer from '../UiElements/UiElementRenderer';
import { useElementEffects } from '../../hooks/useElementEffects';
import {
buildTransitionStyle,
buildAppearAnimationStyle,
hasAnyEffects,
type ElementEffectProperties,
} from '../../lib/elementEffects';
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
interface CanvasElementProps {
@ -32,23 +41,76 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
resolveUrl,
onGalleryCardClick,
}) => {
// 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,
};
// Use effects hook - disabled in edit mode to avoid interfering with dragging
const { effectStyle, eventHandlers } = useElementEffects(
isEditMode ? {} : effectProperties,
);
// Build base position style
let positionStyle: React.CSSProperties = {
left: `${element.xPercent}%`,
top: `${element.yPercent}%`,
transform: 'translate(-50%, -50%)',
// Reset button defaults to let UiElementRenderer control styling
background: 'transparent',
border: 'none',
padding: 0,
margin: 0,
};
// Merge interactive effects (preview mode only)
if (!isEditMode && effectStyle.transform) {
// Preserve the translate, add effect transform
positionStyle.transform = `translate(-50%, -50%) ${effectStyle.transform}`;
// Remove transform from effectStyle to avoid double application
const { transform, ...restEffectStyle } = effectStyle;
positionStyle = { ...positionStyle, ...restEffectStyle };
} else if (!isEditMode) {
positionStyle = { ...positionStyle, ...effectStyle };
}
// Add transition for interactive effects (preview mode only)
if (!isEditMode && hasAnyEffects(effectProperties)) {
positionStyle = { ...positionStyle, ...buildTransitionStyle(effectProperties) };
}
// Add appear animation (ALWAYS - for WYSIWYG)
if (effectProperties.appearAnimation) {
positionStyle = {
...positionStyle,
...buildAppearAnimationStyle(effectProperties),
};
}
return (
<button
type='button'
data-constructor-element-id={element.id}
className='absolute'
style={{
left: `${element.xPercent}%`,
top: `${element.yPercent}%`,
transform: 'translate(-50%, -50%)',
// Reset button defaults to let UiElementRenderer control styling
background: 'transparent',
border: 'none',
padding: 0,
margin: 0,
}}
style={positionStyle}
onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={onClick}
{...(!isEditMode ? eventHandlers : {})}
>
<UiElementRenderer
element={element}

View File

@ -66,22 +66,22 @@ const DescriptionSettingsSection: React.FC<DescriptionSettingsSectionProps> = ({
</FormField>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Title font size'>
<FormField label='Title font size (px)'>
<input
value={descriptionTitleFontSize}
onChange={(event) =>
onChange('descriptionTitleFontSize', event.target.value)
}
placeholder='e.g. 48px'
placeholder='e.g. 48'
/>
</FormField>
<FormField label='Text font size'>
<FormField label='Text font size (px)'>
<input
value={descriptionTextFontSize}
onChange={(event) =>
onChange('descriptionTextFontSize', event.target.value)
}
placeholder='e.g. 36px'
placeholder='e.g. 36'
/>
</FormField>
<FormField label='Title font family'>

View File

@ -88,7 +88,7 @@ const DescriptionSettingsSectionCompact: React.FC<
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Title font size
Title font size (px)
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
@ -96,13 +96,13 @@ const DescriptionSettingsSectionCompact: React.FC<
onChange={(event) =>
onChange('descriptionTitleFontSize', event.target.value)
}
placeholder='e.g. 48px'
placeholder='e.g. 48'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Text font size
Text font size (px)
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
@ -110,7 +110,7 @@ const DescriptionSettingsSectionCompact: React.FC<
onChange={(event) =>
onChange('descriptionTextFontSize', event.target.value)
}
placeholder='e.g. 36px'
placeholder='e.g. 36'
/>
</div>

View File

@ -36,13 +36,13 @@ const EffectsSettingsSection: React.FC<EffectsSettingsSectionProps> = ({
<option value='scale'>Scale</option>
</select>
</FormField>
<FormField label='Duration'>
<FormField label='Duration (sec)'>
<input
value={values.appearAnimationDuration || ''}
onChange={(e) =>
onChange('appearAnimationDuration', e.target.value)
}
placeholder='e.g. 0.3s'
placeholder='e.g. 0.3'
/>
</FormField>
<FormField label='Easing'>
@ -105,13 +105,13 @@ const EffectsSettingsSection: React.FC<EffectsSettingsSectionProps> = ({
placeholder='e.g. 0 4px 12px rgba(...)'
/>
</FormField>
<FormField label='Transition duration'>
<FormField label='Transition duration (sec)'>
<input
value={values.hoverTransitionDuration || ''}
onChange={(e) =>
onChange('hoverTransitionDuration', e.target.value)
}
placeholder='e.g. 0.2s'
placeholder='e.g. 0.2'
/>
</FormField>
</div>

View File

@ -38,7 +38,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
</div>
<div>
<label className='mb-1 block text-[10px] text-gray-500'>
Duration
Duration (sec)
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
@ -46,7 +46,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
onChange={(e) =>
onChange('appearAnimationDuration', e.target.value)
}
placeholder='0.3s'
placeholder='0.3'
/>
</div>
<div>
@ -133,7 +133,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
</div>
<div className='col-span-2'>
<label className='mb-1 block text-[10px] text-gray-500'>
Transition duration
Transition (sec)
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
@ -141,7 +141,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
onChange={(e) =>
onChange('hoverTransitionDuration', e.target.value)
}
placeholder='0.2s'
placeholder='0.2'
/>
</div>
</div>

View File

@ -10,6 +10,7 @@ import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
import { normalizePixelValue } from '../../../lib/elementStyles';
interface DescriptionElementProps {
element: CanvasElement;
@ -72,7 +73,8 @@ const DescriptionElement: React.FC<DescriptionElementProps> = ({
<div className='p-4'>
<p
style={{
fontSize: element.descriptionTitleFontSize || '24px',
fontSize:
normalizePixelValue(element.descriptionTitleFontSize) || '24px',
color: element.descriptionTitleColor || '#ffffff',
fontWeight,
...titleFontStyle,
@ -83,7 +85,8 @@ const DescriptionElement: React.FC<DescriptionElementProps> = ({
{element.descriptionText && (
<p
style={{
fontSize: element.descriptionTextFontSize || '16px',
fontSize:
normalizePixelValue(element.descriptionTextFontSize) || '16px',
color: element.descriptionTextColor || '#ffffff',
fontWeight,
...textFontStyle,

View File

@ -596,8 +596,8 @@ export function usePreloadOrchestrator(
return () => clearReadyBlobUrls();
}, [clearReadyBlobUrls]);
// Initialize ready blob URLs from Cache API for current page's background assets
// This ensures getReadyBlobUrl works on the first render
// Initialize ready blob URLs from Cache API for current page's assets
// This ensures getReadyBlobUrl works on the first render for both backgrounds and element icons
useEffect(() => {
if (!currentPageId) return;
@ -605,13 +605,48 @@ export function usePreloadOrchestrator(
if (!currentPage) return;
const initializeFromCache = async () => {
// Collect background URLs
const bgUrls = [
currentPage.background_image_url,
currentPage.background_video_url,
currentPage.background_audio_url,
].filter(Boolean) as string[];
for (const storagePath of bgUrls) {
// Collect element asset URLs (icons, images, etc.) from current page
const currentPageElements = elements.filter(
(el) => el.pageId === currentPageId,
);
const elementAssetUrls: string[] = [];
currentPageElements.forEach((element) => {
if (!element.content_json) return;
try {
const content =
typeof element.content_json === 'string'
? JSON.parse(element.content_json)
: element.content_json;
// Extract URLs from known asset fields
const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[];
const checkObject = (obj: Record<string, unknown>) => {
if (!obj || typeof obj !== 'object') return;
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string' && value && urlFields.includes(key)) {
elementAssetUrls.push(value);
} else if (typeof value === 'object' && value !== null) {
checkObject(value as Record<string, unknown>);
}
}
};
checkObject(content);
} catch {
// Ignore parse errors
}
});
// Initialize all URLs from cache
const allUrls = [...bgUrls, ...elementAssetUrls];
for (const storagePath of allUrls) {
// Skip if already in memory
if (readyBlobUrlsRef.current.has(storagePath)) continue;
@ -624,7 +659,7 @@ export function usePreloadOrchestrator(
};
initializeFromCache();
}, [currentPageId, pages, createReadyBlobUrl]);
}, [currentPageId, pages, elements, createReadyBlobUrl]);
// React to page changes - preload neighbors
useEffect(() => {

View File

@ -251,6 +251,15 @@ export function useTransitionPlayback(
const video = videoRef.current;
if (video) {
video.pause();
// Seek back slightly to ensure last frame is visible
// Some browsers show black after 'ended' event when currentTime === duration
if (
video.duration &&
Number.isFinite(video.duration) &&
video.currentTime >= video.duration - 0.1
) {
video.currentTime = Math.max(0, video.duration - 0.05);
}
}
const currentTransition = transitionRef.current;
@ -401,9 +410,13 @@ export function useTransitionPlayback(
) {
return;
}
// Finish slightly BEFORE the video ends to ensure last frame is visible
// and prevent browser-specific 'ended' event quirks (black frame)
const finishBeforeEndMs = 50; // 50ms before video naturally ends
const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs);
finishTimerRef.current = setTimeout(
() => finishPlayback('duration-timer'),
durationSec * 1000 + durationBufferMs,
finishMs,
);
};
@ -750,7 +763,6 @@ export function useTransitionPlayback(
transition?.reverseMode, // Re-run effect when reverseMode changes (same video, different direction)
videoRef,
playbackStartMs,
durationBufferMs,
hardTimeoutMs,
clearTimers,
revokeBlobUrl,

View File

@ -7,6 +7,31 @@
import type { CSSProperties } from 'react';
/**
* Normalize duration value to include 's' suffix.
* Accepts: '0.3', '0.3s', '300ms', 0.3
* Returns: '0.3s' (or original if already has valid unit)
*/
function normalizeDuration(value: string | number | undefined): string {
if (!value && value !== 0) return '';
const str = String(value).trim();
if (!str) return '';
// Already has 's' or 'ms' suffix - return as is
if (/^\d*\.?\d+(s|ms)$/i.test(str)) {
return str;
}
// Just a number - append 's'
const num = parseFloat(str);
if (Number.isFinite(num)) {
return `${num}s`;
}
return str;
}
/**
* Appear animation types
*/
@ -76,7 +101,8 @@ export type EffectPropName = (typeof EFFECT_PROPS)[number];
export function buildTransitionStyle(
effects: Partial<ElementEffectProperties>,
): CSSProperties {
const duration = effects.hoverTransitionDuration || '0.2s';
const duration =
normalizeDuration(effects.hoverTransitionDuration) || '0.2s';
return {
transition: `all ${duration} ease`,
};
@ -202,7 +228,8 @@ export function buildAppearAnimationStyle(
return {};
}
const duration = effects.appearAnimationDuration || '0.3s';
const duration =
normalizeDuration(effects.appearAnimationDuration) || '0.3s';
const easing = effects.appearAnimationEasing || 'ease';
return {

View File

@ -7,6 +7,47 @@
import type { CSSProperties } from 'react';
/**
* Normalize a numeric value to include a CSS unit suffix.
* @param value - The value to normalize
* @param unit - The unit to append (e.g., 'px', 'vw', 'vh', 's')
* @returns Normalized value with unit, or empty string if invalid
*/
function normalizeWithUnit(
value: string | number | undefined,
unit: string,
): string {
if (value === null || value === undefined || value === '') return '';
const str = String(value).trim();
if (!str) return '';
// Already has a unit - return as is
if (/[a-z%]+$/i.test(str)) {
return str;
}
// Just a number - append unit
const num = parseFloat(str);
if (Number.isFinite(num)) {
return `${num}${unit}`;
}
return str;
}
/** Normalize pixel values (border, borderRadius, fontSize for description) */
export const normalizePixelValue = (value: string | number | undefined) =>
normalizeWithUnit(value, 'px');
/** Normalize viewport width values (width, minWidth, maxWidth) */
export const normalizeViewportWidth = (value: string | number | undefined) =>
normalizeWithUnit(value, 'vw');
/** Normalize viewport height values (height, minHeight, maxHeight) */
export const normalizeViewportHeight = (value: string | number | undefined) =>
normalizeWithUnit(value, 'vh');
/**
* CSS style properties supported by UI elements.
* These properties can be set in element-type-defaults and applied at runtime.
@ -104,9 +145,29 @@ export function buildElementStyle(
const style: CSSProperties = {};
const source = element as Record<string, unknown>;
// Apply string properties
// Properties that need viewport width unit (vw)
const vwProps = ['width', 'minWidth', 'maxWidth'];
// Properties that need viewport height unit (vh)
const vhProps = ['height', 'minHeight', 'maxHeight'];
// Properties that need pixel unit (px)
const pxProps = ['border', 'borderRadius'];
// Apply string properties with unit normalization where needed
ELEMENT_STYLE_PROPS.forEach((prop) => {
const value = getTrimmedValue(source[prop]);
const rawValue = getTrimmedValue(source[prop]);
if (!rawValue) return;
let value = rawValue;
// Apply unit normalization based on property type
if (vwProps.includes(prop)) {
value = normalizeViewportWidth(rawValue);
} else if (vhProps.includes(prop)) {
value = normalizeViewportHeight(rawValue);
} else if (pxProps.includes(prop)) {
value = normalizePixelValue(rawValue);
}
if (value) {
(style as Record<string, unknown>)[prop] = value;
}