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.
|
* Individual element rendered on the constructor canvas.
|
||||||
* Handles positioning and interaction, delegates styling to UiElementRenderer.
|
* 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 React from 'react';
|
||||||
import UiElementRenderer from '../UiElements/UiElementRenderer';
|
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';
|
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
|
||||||
|
|
||||||
interface CanvasElementProps {
|
interface CanvasElementProps {
|
||||||
@ -32,23 +41,76 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
resolveUrl,
|
resolveUrl,
|
||||||
onGalleryCardClick,
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
data-constructor-element-id={element.id}
|
data-constructor-element-id={element.id}
|
||||||
className='absolute'
|
className='absolute'
|
||||||
style={{
|
style={positionStyle}
|
||||||
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,
|
|
||||||
}}
|
|
||||||
onMouseDown={isEditMode ? onMouseDown : undefined}
|
onMouseDown={isEditMode ? onMouseDown : undefined}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
{...(!isEditMode ? eventHandlers : {})}
|
||||||
>
|
>
|
||||||
<UiElementRenderer
|
<UiElementRenderer
|
||||||
element={element}
|
element={element}
|
||||||
|
|||||||
@ -66,22 +66,22 @@ const DescriptionSettingsSection: React.FC<DescriptionSettingsSectionProps> = ({
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className='grid gap-3 md:grid-cols-2'>
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
<FormField label='Title font size'>
|
<FormField label='Title font size (px)'>
|
||||||
<input
|
<input
|
||||||
value={descriptionTitleFontSize}
|
value={descriptionTitleFontSize}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onChange('descriptionTitleFontSize', event.target.value)
|
onChange('descriptionTitleFontSize', event.target.value)
|
||||||
}
|
}
|
||||||
placeholder='e.g. 48px'
|
placeholder='e.g. 48'
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label='Text font size'>
|
<FormField label='Text font size (px)'>
|
||||||
<input
|
<input
|
||||||
value={descriptionTextFontSize}
|
value={descriptionTextFontSize}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onChange('descriptionTextFontSize', event.target.value)
|
onChange('descriptionTextFontSize', event.target.value)
|
||||||
}
|
}
|
||||||
placeholder='e.g. 36px'
|
placeholder='e.g. 36'
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label='Title font family'>
|
<FormField label='Title font family'>
|
||||||
|
|||||||
@ -88,7 +88,7 @@ const DescriptionSettingsSectionCompact: React.FC<
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
Title font size
|
Title font size (px)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
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={(event) =>
|
||||||
onChange('descriptionTitleFontSize', event.target.value)
|
onChange('descriptionTitleFontSize', event.target.value)
|
||||||
}
|
}
|
||||||
placeholder='e.g. 48px'
|
placeholder='e.g. 48'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
Text font size
|
Text font size (px)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
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={(event) =>
|
||||||
onChange('descriptionTextFontSize', event.target.value)
|
onChange('descriptionTextFontSize', event.target.value)
|
||||||
}
|
}
|
||||||
placeholder='e.g. 36px'
|
placeholder='e.g. 36'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -36,13 +36,13 @@ const EffectsSettingsSection: React.FC<EffectsSettingsSectionProps> = ({
|
|||||||
<option value='scale'>Scale</option>
|
<option value='scale'>Scale</option>
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label='Duration'>
|
<FormField label='Duration (sec)'>
|
||||||
<input
|
<input
|
||||||
value={values.appearAnimationDuration || ''}
|
value={values.appearAnimationDuration || ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange('appearAnimationDuration', e.target.value)
|
onChange('appearAnimationDuration', e.target.value)
|
||||||
}
|
}
|
||||||
placeholder='e.g. 0.3s'
|
placeholder='e.g. 0.3'
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label='Easing'>
|
<FormField label='Easing'>
|
||||||
@ -105,13 +105,13 @@ const EffectsSettingsSection: React.FC<EffectsSettingsSectionProps> = ({
|
|||||||
placeholder='e.g. 0 4px 12px rgba(...)'
|
placeholder='e.g. 0 4px 12px rgba(...)'
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label='Transition duration'>
|
<FormField label='Transition duration (sec)'>
|
||||||
<input
|
<input
|
||||||
value={values.hoverTransitionDuration || ''}
|
value={values.hoverTransitionDuration || ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange('hoverTransitionDuration', e.target.value)
|
onChange('hoverTransitionDuration', e.target.value)
|
||||||
}
|
}
|
||||||
placeholder='e.g. 0.2s'
|
placeholder='e.g. 0.2'
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -38,7 +38,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||||
Duration
|
Duration (sec)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
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={(e) =>
|
||||||
onChange('appearAnimationDuration', e.target.value)
|
onChange('appearAnimationDuration', e.target.value)
|
||||||
}
|
}
|
||||||
placeholder='0.3s'
|
placeholder='0.3'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -133,7 +133,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className='col-span-2'>
|
<div className='col-span-2'>
|
||||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||||
Transition duration
|
Transition (sec)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
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={(e) =>
|
||||||
onChange('hoverTransitionDuration', e.target.value)
|
onChange('hoverTransitionDuration', e.target.value)
|
||||||
}
|
}
|
||||||
placeholder='0.2s'
|
placeholder='0.2'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { CSSProperties } from 'react';
|
|||||||
import type { CanvasElement } from '../../../types/constructor';
|
import type { CanvasElement } from '../../../types/constructor';
|
||||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||||
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
|
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
|
||||||
|
import { normalizePixelValue } from '../../../lib/elementStyles';
|
||||||
|
|
||||||
interface DescriptionElementProps {
|
interface DescriptionElementProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
@ -72,7 +73,8 @@ const DescriptionElement: React.FC<DescriptionElementProps> = ({
|
|||||||
<div className='p-4'>
|
<div className='p-4'>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
fontSize: element.descriptionTitleFontSize || '24px',
|
fontSize:
|
||||||
|
normalizePixelValue(element.descriptionTitleFontSize) || '24px',
|
||||||
color: element.descriptionTitleColor || '#ffffff',
|
color: element.descriptionTitleColor || '#ffffff',
|
||||||
fontWeight,
|
fontWeight,
|
||||||
...titleFontStyle,
|
...titleFontStyle,
|
||||||
@ -83,7 +85,8 @@ const DescriptionElement: React.FC<DescriptionElementProps> = ({
|
|||||||
{element.descriptionText && (
|
{element.descriptionText && (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
fontSize: element.descriptionTextFontSize || '16px',
|
fontSize:
|
||||||
|
normalizePixelValue(element.descriptionTextFontSize) || '16px',
|
||||||
color: element.descriptionTextColor || '#ffffff',
|
color: element.descriptionTextColor || '#ffffff',
|
||||||
fontWeight,
|
fontWeight,
|
||||||
...textFontStyle,
|
...textFontStyle,
|
||||||
|
|||||||
@ -596,8 +596,8 @@ export function usePreloadOrchestrator(
|
|||||||
return () => clearReadyBlobUrls();
|
return () => clearReadyBlobUrls();
|
||||||
}, [clearReadyBlobUrls]);
|
}, [clearReadyBlobUrls]);
|
||||||
|
|
||||||
// Initialize ready blob URLs from Cache API for current page's background assets
|
// Initialize ready blob URLs from Cache API for current page's assets
|
||||||
// This ensures getReadyBlobUrl works on the first render
|
// This ensures getReadyBlobUrl works on the first render for both backgrounds and element icons
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentPageId) return;
|
if (!currentPageId) return;
|
||||||
|
|
||||||
@ -605,13 +605,48 @@ export function usePreloadOrchestrator(
|
|||||||
if (!currentPage) return;
|
if (!currentPage) return;
|
||||||
|
|
||||||
const initializeFromCache = async () => {
|
const initializeFromCache = async () => {
|
||||||
|
// Collect background URLs
|
||||||
const bgUrls = [
|
const bgUrls = [
|
||||||
currentPage.background_image_url,
|
currentPage.background_image_url,
|
||||||
currentPage.background_video_url,
|
currentPage.background_video_url,
|
||||||
currentPage.background_audio_url,
|
currentPage.background_audio_url,
|
||||||
].filter(Boolean) as string[];
|
].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
|
// Skip if already in memory
|
||||||
if (readyBlobUrlsRef.current.has(storagePath)) continue;
|
if (readyBlobUrlsRef.current.has(storagePath)) continue;
|
||||||
|
|
||||||
@ -624,7 +659,7 @@ export function usePreloadOrchestrator(
|
|||||||
};
|
};
|
||||||
|
|
||||||
initializeFromCache();
|
initializeFromCache();
|
||||||
}, [currentPageId, pages, createReadyBlobUrl]);
|
}, [currentPageId, pages, elements, createReadyBlobUrl]);
|
||||||
|
|
||||||
// React to page changes - preload neighbors
|
// React to page changes - preload neighbors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -251,6 +251,15 @@ export function useTransitionPlayback(
|
|||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (video) {
|
if (video) {
|
||||||
video.pause();
|
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;
|
const currentTransition = transitionRef.current;
|
||||||
@ -401,9 +410,13 @@ export function useTransitionPlayback(
|
|||||||
) {
|
) {
|
||||||
return;
|
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(
|
finishTimerRef.current = setTimeout(
|
||||||
() => finishPlayback('duration-timer'),
|
() => 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)
|
transition?.reverseMode, // Re-run effect when reverseMode changes (same video, different direction)
|
||||||
videoRef,
|
videoRef,
|
||||||
playbackStartMs,
|
playbackStartMs,
|
||||||
durationBufferMs,
|
|
||||||
hardTimeoutMs,
|
hardTimeoutMs,
|
||||||
clearTimers,
|
clearTimers,
|
||||||
revokeBlobUrl,
|
revokeBlobUrl,
|
||||||
|
|||||||
@ -7,6 +7,31 @@
|
|||||||
|
|
||||||
import type { CSSProperties } from 'react';
|
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
|
* Appear animation types
|
||||||
*/
|
*/
|
||||||
@ -76,7 +101,8 @@ export type EffectPropName = (typeof EFFECT_PROPS)[number];
|
|||||||
export function buildTransitionStyle(
|
export function buildTransitionStyle(
|
||||||
effects: Partial<ElementEffectProperties>,
|
effects: Partial<ElementEffectProperties>,
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
const duration = effects.hoverTransitionDuration || '0.2s';
|
const duration =
|
||||||
|
normalizeDuration(effects.hoverTransitionDuration) || '0.2s';
|
||||||
return {
|
return {
|
||||||
transition: `all ${duration} ease`,
|
transition: `all ${duration} ease`,
|
||||||
};
|
};
|
||||||
@ -202,7 +228,8 @@ export function buildAppearAnimationStyle(
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = effects.appearAnimationDuration || '0.3s';
|
const duration =
|
||||||
|
normalizeDuration(effects.appearAnimationDuration) || '0.3s';
|
||||||
const easing = effects.appearAnimationEasing || 'ease';
|
const easing = effects.appearAnimationEasing || 'ease';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -7,6 +7,47 @@
|
|||||||
|
|
||||||
import type { CSSProperties } from 'react';
|
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.
|
* CSS style properties supported by UI elements.
|
||||||
* These properties can be set in element-type-defaults and applied at runtime.
|
* These properties can be set in element-type-defaults and applied at runtime.
|
||||||
@ -104,9 +145,29 @@ export function buildElementStyle(
|
|||||||
const style: CSSProperties = {};
|
const style: CSSProperties = {};
|
||||||
const source = element as Record<string, unknown>;
|
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) => {
|
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) {
|
if (value) {
|
||||||
(style as Record<string, unknown>)[prop] = value;
|
(style as Record<string, unknown>)[prop] = value;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user