fixed smooth transition issue and effects
This commit is contained in:
parent
a454109b79
commit
d1de154b35
File diff suppressed because one or more lines are too long
@ -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}
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user