added fade effects to gallery and for carousel slides.

This commit is contained in:
Dmitri 2026-05-04 16:49:43 +02:00
parent e855db03d1
commit e4dd94f478
22 changed files with 826 additions and 108 deletions

View File

@ -200,7 +200,7 @@ const mountRuntimeEntityRoute = (path, entityName, router) => {
app.use(
path,
requireRuntimeReadOrAuth,
blockNonPublicRuntimeListEndpoints,
blockNonPublicRuntimeListEndpoints(entityName),
sanitizePublicRuntimeListResponse(entityName),
router,
);
@ -237,18 +237,11 @@ app.use(
jwtAuth,
project_element_defaultsRoutes,
);
app.use(
'/api/global-transition-defaults',
jwtAuth,
global_transition_defaultsRoutes,
);
// Global transition defaults - routes handle their own auth (GET public, PUT protected)
app.use('/api/global-transition-defaults', global_transition_defaultsRoutes);
// Environment-aware project transition settings (supports runtime public access)
mountRuntimeEntityRoute(
'/api/project-transition-settings',
'project_transition_settings',
project_transition_settingsRoutes,
);
// Project transition settings - routes handle their own auth (production GET public, else protected)
app.use('/api/project-transition-settings', project_transition_settingsRoutes);
app.use('/api/publish', jwtAuth, publishRoutes);

View File

@ -161,6 +161,8 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PAGE_LINKS',
'TRANSITIONS',
'PROJECT_AUDIO_TRACKS',
'GLOBAL_TRANSITION_DEFAULTS',
'PROJECT_TRANSITION_SETTINGS',
]);
/**

View File

@ -1,5 +1,3 @@
const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
const PUBLIC_RUNTIME_ENTITY_FIELDS = {
projects: [
'id',
@ -38,6 +36,31 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
'sort_order',
'is_enabled',
],
global_transition_defaults: [
'id',
'transition_type',
'duration_ms',
'easing',
'overlay_color',
],
project_transition_settings: [
'id',
'projectId',
'environment',
'transition_type',
'duration_ms',
'easing',
'overlay_color',
],
};
// Entity-aware path patterns for public runtime access
// Entities not listed here default to allowing only '/'
const PUBLIC_RUNTIME_ALLOWED_PATHS = {
project_transition_settings: [
'/',
/^\/project\/[a-fA-F0-9-]+\/env\/(dev|stage|production)$/,
],
};
const pickFields = (record, fields) => {
@ -61,12 +84,17 @@ const isPublicRuntimeReadRequest = (req) => {
return req.isRuntimePublicRequest === true && req.method === 'GET';
};
const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
const blockNonPublicRuntimeListEndpoints = (entityName) => (req, res, next) => {
if (!isPublicRuntimeReadRequest(req)) {
return next();
}
if (req.path !== PUBLIC_RUNTIME_ALLOWED_PATH) {
const allowedPaths = PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] || ['/'];
const pathMatches = allowedPaths.some((pattern) =>
pattern instanceof RegExp ? pattern.test(req.path) : req.path === pattern,
);
if (!pathMatches) {
return res.status(404).send({ message: 'Not found' });
}
@ -79,13 +107,14 @@ const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
const sanitizePublicRuntimeListResponse = (entityName) => {
const fields = PUBLIC_RUNTIME_ENTITY_FIELDS[entityName] || [];
const allowedPaths = PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] || ['/'];
return (req, res, next) => {
if (
!isPublicRuntimeReadRequest(req) ||
req.path !== PUBLIC_RUNTIME_ALLOWED_PATH ||
fields.length === 0
) {
const pathMatches = allowedPaths.some((pattern) =>
pattern instanceof RegExp ? pattern.test(req.path) : req.path === pattern,
);
if (!isPublicRuntimeReadRequest(req) || !pathMatches || fields.length === 0) {
return next();
}
@ -96,16 +125,21 @@ const sanitizePublicRuntimeListResponse = (entityName) => {
return originalSend(body);
}
if (!Array.isArray(body.rows)) {
return originalSend(body);
// Handle list responses with rows array
if (Array.isArray(body.rows)) {
const sanitizedRows = body.rows.map((row) => pickFields(row, fields));
return originalSend({
...body,
rows: sanitizedRows,
});
}
const sanitizedRows = body.rows.map((row) => pickFields(row, fields));
// Handle single object responses (e.g., from findOne or project/:id/env/:env)
if (!Array.isArray(body) && body !== null) {
return originalSend(pickFields(body, fields));
}
return originalSend({
...body,
rows: sanitizedRows,
});
return originalSend(body);
};
return next();

View File

@ -1,13 +1,26 @@
const express = require('express');
const passport = require('passport');
const Global_transition_defaultsService = require('../services/global_transition_defaults');
const Global_transition_defaultsDBApi = require('../db/api/global_transition_defaults');
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
const { checkCrudPermissions } = require('../middlewares/check-permissions');
const router = express.Router();
const jwtAuth = passport.authenticate('jwt', { session: false });
// Use page_elements permission (same as element_type_defaults)
router.use(checkCrudPermissions('page_elements'));
/**
* Middleware for public GET access.
* Marks GET requests as public runtime requests for permission bypass.
*/
const allowPublicRead = (req, _res, next) => {
if (['GET', 'OPTIONS'].includes(req.method)) {
req.isRuntimePublicRequest = true;
}
return next();
};
// Apply CRUD permission checks (handles public bypass via isRuntimePublicRequest)
router.use(checkCrudPermissions('global_transition_defaults'));
/**
* @swagger
@ -15,14 +28,14 @@ router.use(checkCrudPermissions('page_elements'));
* get:
* summary: Get global transition defaults (singleton)
* tags: [GlobalTransitionDefaults]
* security:
* - bearerAuth: []
* description: Publicly accessible - no authentication required.
* responses:
* 200:
* description: Global transition defaults settings
*/
router.get(
'/',
allowPublicRead,
wrapAsync(async (_req, res) => {
const payload = await Global_transition_defaultsDBApi.findOne();
res.status(200).send(payload);
@ -35,8 +48,7 @@ router.get(
* get:
* summary: Get global transition defaults by ID
* tags: [GlobalTransitionDefaults]
* security:
* - bearerAuth: []
* description: Publicly accessible - no authentication required.
* parameters:
* - in: path
* name: id
@ -50,6 +62,7 @@ router.get(
*/
router.get(
'/:id',
allowPublicRead,
wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send('Invalid global_transition_defaults id');
@ -102,6 +115,7 @@ router.get(
*/
router.put(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
await Global_transition_defaultsService.update(
req.body.data,

View File

@ -1,13 +1,34 @@
const express = require('express');
const passport = require('passport');
const Project_transition_settingsService = require('../services/project_transition_settings');
const Project_transition_settingsDBApi = require('../db/api/project_transition_settings');
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
const { checkCrudPermissions } = require('../middlewares/check-permissions');
const router = express.Router();
const jwtAuth = passport.authenticate('jwt', { session: false });
// Use page_elements permission (same as other element/transition settings)
router.use(checkCrudPermissions('page_elements'));
/**
* Middleware: Production GET is public, everything else requires JWT.
* Determines public access from URL path, not headers.
*/
const requireProductionOrAuth = (req, res, next) => {
const { environment } = req.params;
const isProduction = environment === 'production';
const isReadOnly = ['GET', 'OPTIONS'].includes(req.method);
if (isProduction && isReadOnly) {
// Public access for production GET - mark for permission bypass
req.isRuntimePublicRequest = true;
return next();
}
// Require JWT for non-production or write operations
return jwtAuth(req, res, next);
};
// Apply CRUD permission checks (handles public bypass via isRuntimePublicRequest)
router.use(checkCrudPermissions('project_transition_settings'));
/**
* @swagger
@ -22,8 +43,7 @@ router.use(checkCrudPermissions('page_elements'));
* get:
* summary: Get transition settings for a project in a specific environment
* tags: [Project_transition_settings]
* security:
* - bearerAuth: []
* description: Production environment is publicly accessible. Dev/stage require authentication.
* parameters:
* - in: path
* name: projectId
@ -39,12 +59,13 @@ router.use(checkCrudPermissions('page_elements'));
* enum: [dev, stage, production]
* responses:
* 200:
* description: Transition settings for the project/environment
* 404:
* description: No settings found (use global defaults)
* description: Transition settings for the project/environment (null if none exist)
* 401:
* description: Authentication required (for dev/stage environments)
*/
router.get(
'/project/:projectId/env/:environment',
requireProductionOrAuth,
wrapAsync(async (req, res) => {
const { projectId, environment } = req.params;
@ -63,10 +84,7 @@ router.get(
req.currentUser,
);
if (!settings) {
return res.status(404).send({ message: 'No project-specific settings found' });
}
// Return null if no settings exist (frontend will use global defaults)
res.status(200).send(settings);
}),
);
@ -119,6 +137,7 @@ router.get(
*/
router.put(
'/project/:projectId/env/:environment',
jwtAuth,
wrapAsync(async (req, res) => {
const { projectId, environment } = req.params;
@ -168,6 +187,7 @@ router.put(
*/
router.delete(
'/project/:projectId/env/:environment',
jwtAuth,
wrapAsync(async (req, res) => {
const { projectId, environment } = req.params;
@ -211,6 +231,7 @@ router.delete(
*/
router.get(
'/',
jwtAuth,
wrapAsync(async (req, res) => {
const payload = await Project_transition_settingsDBApi.findAll(req.query);
res.status(200).send(payload);
@ -240,6 +261,7 @@ router.get(
*/
router.post(
'/',
jwtAuth,
wrapAsync(async (req, res) => {
const payload = await Project_transition_settingsService.create(
req.body.data,
@ -270,6 +292,7 @@ router.post(
*/
router.get(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send({ message: 'Invalid ID' });
@ -312,6 +335,7 @@ router.get(
*/
router.put(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
await Project_transition_settingsService.update(
req.body.data,
@ -343,6 +367,7 @@ router.put(
*/
router.delete(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
await Project_transition_settingsService.remove(
req.params.id,

View File

@ -17,6 +17,7 @@ import {
type ElementEffectProperties,
} from '../../lib/elementEffects';
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition';
interface CanvasElementProps {
element: CanvasElementType;
@ -37,6 +38,8 @@ interface CanvasElementProps {
) => void;
/** Letterbox styles for constraining fullscreen elements to canvas bounds */
letterboxStyles?: React.CSSProperties;
/** Page transition settings (for slide transition cascade in carousel/gallery) */
pageTransitionSettings?: ResolvedTransitionSettings;
}
const CanvasElement: React.FC<CanvasElementProps> = ({
@ -50,6 +53,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
onGalleryCardClick,
onCarouselButtonPositionChange,
letterboxStyles,
pageTransitionSettings,
}) => {
// Extract effect properties from element
const effectProperties: Partial<ElementEffectProperties> = {
@ -140,6 +144,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
/>
</button>
);

View File

@ -741,6 +741,7 @@ export function ElementEditorPanel({
{/* Effects Tab */}
{activeTab === 'effects' && (
<EffectsSettingsSectionCompact
elementType={selectedElement.type}
values={{
appearAnimation: selectedElement.appearAnimation || '',
appearAnimationDuration:
@ -763,11 +764,104 @@ export function ElementEditorPanel({
activeOpacity: selectedElement.activeOpacity || '',
activeBackgroundColor:
selectedElement.activeBackgroundColor || '',
// Slide transition values (gallery/carousel)
slideTransitionType:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionType || ''
: selectedElement.carouselSlideTransitionType || '',
slideTransitionDurationMs:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionDurationMs !==
undefined &&
selectedElement.gallerySlideTransitionDurationMs !== ''
? String(
selectedElement.gallerySlideTransitionDurationMs,
)
: ''
: selectedElement.carouselSlideTransitionDurationMs !==
undefined &&
selectedElement.carouselSlideTransitionDurationMs !==
''
? String(
selectedElement.carouselSlideTransitionDurationMs,
)
: '',
slideTransitionEasing:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionEasing || ''
: selectedElement.carouselSlideTransitionEasing || '',
slideTransitionOverlayColor:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionOverlayColor ||
''
: selectedElement.carouselSlideTransitionOverlayColor ||
'',
}}
onChange={(prop, value) => {
updateSelectedElement({
[prop]: value || undefined,
});
// Handle slide transition properties with proper prefixes
if (prop === 'slideTransitionType') {
const typedValue = (value || undefined) as
| 'fade'
| 'none'
| ''
| undefined;
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionType: typedValue,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionType: typedValue,
});
}
} else if (prop === 'slideTransitionDurationMs') {
const ms = value ? parseInt(value, 10) : undefined;
const typedMs = ms !== undefined && ms > 0 ? ms : '';
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionDurationMs: typedMs,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionDurationMs: typedMs,
});
}
} else if (prop === 'slideTransitionEasing') {
// Cast to proper type - form values are validated by select options
type EasingValue =
| 'ease-in-out'
| 'ease-in'
| 'ease-out'
| 'linear'
| ''
| undefined;
const typedEasing = (value || undefined) as EasingValue;
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionEasing: typedEasing,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionEasing: typedEasing,
});
}
} else if (prop === 'slideTransitionOverlayColor') {
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionOverlayColor: value || undefined,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionOverlayColor:
value || undefined,
});
}
} else {
// Standard effect properties
updateSelectedElement({
[prop]: value || undefined,
});
}
}}
/>
)}

View File

@ -6,12 +6,20 @@
*/
import React from 'react';
import type { EffectsSettingsSectionProps } from './types';
import type { EffectsSettingsFormValues } from './types';
import type { CanvasElementType } from '../../types/constructor';
const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
values,
onChange,
}) => {
interface EffectsSettingsSectionCompactProps {
values: EffectsSettingsFormValues;
onChange: (prop: keyof EffectsSettingsFormValues, value: string) => void;
elementType?: CanvasElementType;
}
const EffectsSettingsSectionCompact: React.FC<
EffectsSettingsSectionCompactProps
> = ({ values, onChange, elementType }) => {
const showSlideTransition =
elementType === 'gallery' || elementType === 'carousel';
return (
<div className='space-y-3'>
{/* Appear Animation */}
@ -243,6 +251,99 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
</div>
</div>
</div>
{/* Slide Transition Override - Gallery/Carousel only */}
{showSlideTransition && (
<div className='mt-3 border-t border-gray-200 pt-3'>
<p className='mb-1 text-[11px] font-semibold text-gray-700'>
Slide Transition
</p>
<p className='mb-2 text-[10px] text-gray-500'>
Override page transition for slides. Leave empty for defaults.
</p>
<div className='grid grid-cols-2 gap-2'>
{/* Type */}
<div>
<label className='mb-1 block text-[10px] text-gray-500'>
Type
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionType || ''}
onChange={(e) => onChange('slideTransitionType', e.target.value)}
>
<option value=''>Use Default</option>
<option value='fade'>Fade</option>
<option value='none'>None (Instant)</option>
</select>
</div>
{/* Duration */}
<div>
<label className='mb-1 block text-[10px] text-gray-500'>
Duration (ms)
</label>
<input
type='number'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionDurationMs || ''}
onChange={(e) =>
onChange('slideTransitionDurationMs', e.target.value)
}
placeholder='400'
min='0'
step='50'
/>
</div>
{/* Easing */}
<div>
<label className='mb-1 block text-[10px] text-gray-500'>
Easing
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionEasing || ''}
onChange={(e) =>
onChange('slideTransitionEasing', e.target.value)
}
>
<option value=''>Use Default</option>
<option value='ease-in-out'>Ease In-Out</option>
<option value='ease-in'>Ease In</option>
<option value='ease-out'>Ease Out</option>
<option value='linear'>Linear</option>
</select>
</div>
{/* Overlay Color */}
<div>
<label className='mb-1 block text-[10px] text-gray-500'>
Overlay Color
</label>
<div className='flex gap-1'>
<input
type='color'
className='h-[26px] w-8 cursor-pointer rounded border border-gray-300'
value={values.slideTransitionOverlayColor || '#000000'}
onChange={(e) =>
onChange('slideTransitionOverlayColor', e.target.value)
}
/>
<input
type='text'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionOverlayColor || ''}
onChange={(e) =>
onChange('slideTransitionOverlayColor', e.target.value)
}
placeholder='#000000'
/>
</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -53,6 +53,12 @@ export interface EffectsSettingsFormValues {
activeScale?: string;
activeOpacity?: string;
activeBackgroundColor?: string;
// Slide transition override (Gallery/Carousel only)
// These override page transition settings for this element's slides
slideTransitionType?: string;
slideTransitionDurationMs?: string;
slideTransitionEasing?: string;
slideTransitionOverlayColor?: string;
}
/**

View File

@ -18,6 +18,7 @@ import {
import { isNavigationElementType } from '../lib/elementDefaults';
import { isBackNavigation } from '../lib/navigationHelpers';
import type { CanvasElement } from '../types/constructor';
import type { ResolvedTransitionSettings } from '../types/transition';
interface RuntimeElementProps {
element: CanvasElement;
@ -30,6 +31,8 @@ interface RuntimeElementProps {
letterboxStyles?: React.CSSProperties;
/** Whether forward navigation is disabled (neighbor pages not yet preloaded) */
isForwardNavDisabled?: boolean;
/** Page transition settings (for slide transition cascade in carousel/gallery) */
pageTransitionSettings?: ResolvedTransitionSettings;
}
// Clamp position to canvas bounds (0-100%)
@ -43,6 +46,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
onGalleryCardClick,
letterboxStyles,
isForwardNavDisabled = false,
pageTransitionSettings,
}) => {
// Clamp coordinates to canvas bounds
const xPercent = clamp(element.xPercent ?? 50, 0, 100);
@ -124,6 +128,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles}
isDisabled={isDisabled}
pageTransitionSettings={pageTransitionSettings}
/>
</div>
);

View File

@ -93,7 +93,7 @@ export default function RuntimePresentation({
},
);
// Fetch global transition defaults on mount
// Fetch global transition defaults on mount (public endpoint, no auth needed)
useEffect(() => {
dispatch(fetchGlobalTransitionDefaults());
}, [dispatch]);
@ -796,6 +796,7 @@ export default function RuntimePresentation({
}
letterboxStyles={letterboxStyles}
isForwardNavDisabled={isForwardNavDisabled}
pageTransitionSettings={transitionSettings}
/>
))}
</div>
@ -879,6 +880,8 @@ export default function RuntimePresentation({
}
letterboxStyles={letterboxStyles}
isEditMode={false}
pageTransitionSettings={transitionSettings}
galleryElement={activeGalleryCarousel.element}
/>
)}
</BackdropPortalProvider>

View File

@ -9,8 +9,14 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import Icon from '@mdi/react';
import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js';
import type { GalleryCard } from '../../types/constructor';
import type { GalleryCard, CanvasElement } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import {
resolveSlideTransition,
extractGallerySlideOverride,
} from '../../lib/resolveSlideTransition';
import { useSlideTransition } from '../../hooks/useSlideTransition';
interface GalleryCarouselOverlayProps {
cards: GalleryCard[];
@ -46,6 +52,10 @@ interface GalleryCarouselOverlayProps {
) => void;
// Letterbox styles for constraining overlay to canvas bounds
letterboxStyles?: React.CSSProperties;
// Page transition settings (for slide transition cascade)
pageTransitionSettings?: ResolvedTransitionSettings;
// Gallery element (for extracting slide transition override)
galleryElement?: CanvasElement;
}
const clamp = (value: number, min: number, max: number) =>
@ -75,9 +85,33 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
isEditMode = false,
onButtonPositionChange,
letterboxStyles,
pageTransitionSettings,
galleryElement,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const [currentIndex, setCurrentIndex] = useState(initialIndex);
// Resolve slide transition with cascade
const slideTransition = resolveSlideTransition(
pageTransitionSettings,
extractGallerySlideOverride(galleryElement),
);
// Use hook for animation state
const {
displayIndex,
overlayOpacity,
overlayColor,
goToIndex,
setInitialIndex,
slideTransitionStyle,
overlayTransitionStyle,
slideOpacity,
} = useSlideTransition(slideTransition);
// Set initial index on mount
useEffect(() => {
setInitialIndex(initialIndex);
}, [initialIndex, setInitialIndex]);
const [draggingButton, setDraggingButton] = useState<
'prev' | 'next' | 'back' | null
>(null);
@ -111,13 +145,15 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
// Navigation handlers
const goToPrev = useCallback(() => {
if (cards.length === 0) return;
setCurrentIndex((prev) => (prev - 1 + cards.length) % cards.length);
}, [cards.length]);
const newIndex = (displayIndex - 1 + cards.length) % cards.length;
goToIndex(newIndex);
}, [cards.length, displayIndex, goToIndex]);
const goToNext = useCallback(() => {
if (cards.length === 0) return;
setCurrentIndex((prev) => (prev + 1) % cards.length);
}, [cards.length]);
const newIndex = (displayIndex + 1) % cards.length;
goToIndex(newIndex);
}, [cards.length, displayIndex, goToIndex]);
// Keyboard navigation
useEffect(() => {
@ -348,7 +384,7 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
);
};
const currentCard = cards[currentIndex];
const currentCard = cards[displayIndex];
const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : '';
return (
@ -377,9 +413,21 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
src={imageUrl}
alt={currentCard?.title || ''}
className='absolute inset-0 h-full w-full object-contain'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
draggable={false}
/>
)}
{/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */}
{slideTransition.type === 'fade' && (
<div
className='absolute inset-0 pointer-events-none'
style={{
...overlayTransitionStyle,
backgroundColor: overlayColor,
opacity: overlayOpacity,
}}
/>
)}
{/* Prev button */}
{renderNavButton(

View File

@ -10,6 +10,7 @@
import React from 'react';
import type { CanvasElement } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition';
import { useElementWrapperStyle } from './shared/useElementWrapperStyle';
import {
isNavigationElementType,
@ -53,6 +54,8 @@ export interface UiElementRendererProps {
) => void;
// Letterbox styles for constraining fullscreen elements to canvas bounds
letterboxStyles?: React.CSSProperties;
// Page transition settings (for slide transition cascade in carousel/gallery)
pageTransitionSettings?: ResolvedTransitionSettings;
}
/**
@ -70,6 +73,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
onGalleryCardClick,
onCarouselButtonPositionChange,
letterboxStyles,
pageTransitionSettings,
}) => {
const { className, style } = useElementWrapperStyle({
element,
@ -101,6 +105,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
isEditMode={isEditMode}
onButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
/>
);
}

View File

@ -14,21 +14,21 @@
* - Navigation-style rendering when custom icons with dimensions are set
*/
import React, {
useState,
useMemo,
useCallback,
useEffect,
useRef,
} from 'react';
import React, { useMemo, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CSSProperties } from 'react';
import Icon from '@mdi/react';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
import type { ResolvedTransitionSettings } from '../../../types/transition';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
import { toCU } from '../../../lib/canvasScale';
import {
resolveSlideTransition,
extractCarouselSlideOverride,
} from '../../../lib/resolveSlideTransition';
import { useSlideTransition } from '../../../hooks/useSlideTransition';
interface CarouselElementProps {
element: CanvasElement;
@ -44,6 +44,8 @@ interface CarouselElementProps {
) => void;
// Letterbox styles for constraining full-width carousel to canvas bounds
letterboxStyles?: CSSProperties;
// Page transition settings (for slide transition cascade)
pageTransitionSettings?: ResolvedTransitionSettings;
}
const clamp = (value: number, min: number, max: number) =>
@ -57,13 +59,31 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
isEditMode = false,
onButtonPositionChange,
letterboxStyles,
pageTransitionSettings,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const slides: CarouselSlide[] = element.carouselSlides || [];
const [currentIndex, setCurrentIndex] = useState(0);
const currentSlide = slides[currentIndex] || slides[0];
const isFullWidth = element.carouselFullWidth || false;
// Resolve slide transition with cascade
const slideTransition = resolveSlideTransition(
pageTransitionSettings,
extractCarouselSlideOverride(element),
);
// Use hook for animation state
const {
displayIndex,
overlayOpacity,
overlayColor,
goToIndex,
slideTransitionStyle,
overlayTransitionStyle,
slideOpacity,
} = useSlideTransition(slideTransition);
const currentSlide = slides[displayIndex] || slides[0];
// Drag state (constructor edit mode only)
const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>(
null,
@ -100,13 +120,15 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
// Navigation handlers (no event parameter for keyboard/swipe use)
const goToPrev = useCallback(() => {
if (slides.length === 0) return;
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length);
}, [slides.length]);
const newIndex = (displayIndex - 1 + slides.length) % slides.length;
goToIndex(newIndex);
}, [slides.length, displayIndex, goToIndex]);
const goToNext = useCallback(() => {
if (slides.length === 0) return;
setCurrentIndex((prev) => (prev + 1) % slides.length);
}, [slides.length]);
const newIndex = (displayIndex + 1) % slides.length;
goToIndex(newIndex);
}, [slides.length, displayIndex, goToIndex]);
// Click handlers for buttons (with event propagation control)
const handlePrevClick = useCallback(
@ -365,9 +387,21 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
src={resolve(currentSlide.imageUrl)}
alt={currentSlide.caption || 'Carousel slide'}
className='absolute inset-0 w-full h-full object-contain'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
draggable={false}
/>
)}
{/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */}
{slideTransition.type === 'fade' && (
<div
className='absolute inset-0 pointer-events-none'
style={{
...overlayTransitionStyle,
backgroundColor: overlayColor,
opacity: overlayOpacity,
}}
/>
)}
</div>
</div>
);
@ -459,7 +493,7 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
// Normal mode: inline carousel within element dimensions
return (
<div className={className} style={style}>
<div className='relative w-full h-full min-w-[120px] min-h-[80px]'>
<div className='relative w-full h-full min-w-[120px] min-h-[80px] overflow-hidden'>
{/* Current slide image */}
{currentSlide?.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
@ -467,9 +501,21 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
src={resolve(currentSlide.imageUrl)}
alt={currentSlide.caption || 'Carousel slide'}
className='w-full h-full object-cover rounded'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
draggable={false}
/>
)}
{/* Transition overlay */}
{slideTransition.type === 'fade' && (
<div
className='absolute inset-0 pointer-events-none rounded'
style={{
...overlayTransitionStyle,
backgroundColor: overlayColor,
opacity: overlayOpacity,
}}
/>
)}
{/* Navigation buttons */}
{showNavigation && (

View File

@ -54,6 +54,7 @@ export type {
UseTransitionCreationOptions,
UseTransitionCreationResult,
} from './useTransitionCreation';
export { useSlideTransition } from './useSlideTransition';
// Constructor hooks - import directly for better tree-shaking:
// import { useOutsideClick } from '../hooks/useOutsideClick';

View File

@ -0,0 +1,159 @@
/**
* useSlideTransition Hook
*
* Manages slide transition animation for Gallery/Carousel elements.
* Implements fade-through-overlay: Slide 1 -> fade out -> overlay -> fade in -> Slide 2
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { CSSProperties } from 'react';
import type { ResolvedSlideTransition } from '../lib/resolveSlideTransition';
// Transition phases: idle -> fadingOut -> fadingIn -> idle
type TransitionPhase = 'idle' | 'fadingOut' | 'fadingIn';
interface SlideTransitionState {
currentIndex: number;
displayIndex: number; // What's actually shown (may differ during transition)
phase: TransitionPhase;
overlayOpacity: number; // 0 = hidden, 1 = fully visible
}
interface UseSlideTransitionReturn {
/** Current logical index */
currentIndex: number;
/** Index to display (follows currentIndex with delay during transition) */
displayIndex: number;
/** Current transition phase */
phase: TransitionPhase;
/** Whether any transition is active */
isTransitioning: boolean;
/** Overlay opacity (0-1) */
overlayOpacity: number;
/** Overlay color from settings */
overlayColor: string;
/** Navigate to specific slide index */
goToIndex: (index: number) => void;
/** Set initial index without transition */
setInitialIndex: (index: number) => void;
/** CSS transition style for slide image */
slideTransitionStyle: CSSProperties;
/** CSS transition style for overlay */
overlayTransitionStyle: CSSProperties;
/** Current slide opacity */
slideOpacity: number;
}
export function useSlideTransition(
settings: ResolvedSlideTransition,
): UseSlideTransitionReturn {
const [state, setState] = useState<SlideTransitionState>({
currentIndex: 0,
displayIndex: 0,
phase: 'idle',
overlayOpacity: 0,
});
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingIndexRef = useRef<number | null>(null);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
// Half duration for each phase (fade out + fade in)
const halfDuration = settings.durationMs / 2;
const goToIndex = useCallback(
(newIndex: number) => {
if (newIndex === state.currentIndex && state.phase === 'idle') return;
// Clear pending transition
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (settings.type === 'none') {
// Instant switch - no transition
setState({
currentIndex: newIndex,
displayIndex: newIndex,
phase: 'idle',
overlayOpacity: 0,
});
return;
}
// Store pending index
pendingIndexRef.current = newIndex;
// Phase 1: Fade out current slide (overlay fades in)
setState((prev) => ({
...prev,
currentIndex: newIndex,
phase: 'fadingOut',
overlayOpacity: 1,
}));
// Phase 2: At midpoint, switch display to new slide, start fade in
timeoutRef.current = setTimeout(() => {
setState((prev) => ({
...prev,
displayIndex: pendingIndexRef.current ?? prev.currentIndex,
phase: 'fadingIn',
overlayOpacity: 0,
}));
// Phase 3: Complete transition
timeoutRef.current = setTimeout(() => {
setState((prev) => ({
...prev,
phase: 'idle',
}));
pendingIndexRef.current = null;
}, halfDuration);
}, halfDuration);
},
[state.currentIndex, state.phase, settings.type, halfDuration],
);
const setInitialIndex = useCallback((index: number) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
pendingIndexRef.current = null;
setState({
currentIndex: index,
displayIndex: index,
phase: 'idle',
overlayOpacity: 0,
});
}, []);
// CSS transition styles
const slideTransitionStyle: CSSProperties =
settings.type === 'fade'
? { transition: `opacity ${halfDuration}ms ${settings.easing}` }
: {};
const overlayTransitionStyle: CSSProperties =
settings.type === 'fade'
? { transition: `opacity ${halfDuration}ms ${settings.easing}` }
: {};
// Slide opacity: visible in idle, fades based on phase
const slideOpacity = state.phase === 'fadingOut' ? 0 : 1;
return {
currentIndex: state.currentIndex,
displayIndex: state.displayIndex,
phase: state.phase,
isTransitioning: state.phase !== 'idle',
overlayOpacity: state.overlayOpacity,
overlayColor: settings.overlayColor,
goToIndex,
setInitialIndex,
slideTransitionStyle,
overlayTransitionStyle,
slideOpacity,
};
}

View File

@ -0,0 +1,147 @@
/**
* Resolve Slide Transition Settings
*
* Cascade: Page Transition Settings -> Element Override
*
* Unlike page transitions, slide transitions:
* - Map 'video' type to 'fade' (no video between slides)
* - Support fade through overlay for crossfade effect
*/
import type {
ResolvedTransitionSettings,
EasingFunction,
SlideTransitionType,
} from '../types/transition';
export interface SlideTransitionOverride {
type?: SlideTransitionType | '' | undefined;
durationMs?: number | '' | undefined;
easing?: EasingFunction | '' | undefined;
overlayColor?: string | undefined;
}
export interface ResolvedSlideTransition {
type: SlideTransitionType;
durationMs: number;
easing: EasingFunction;
overlayColor: string;
}
/**
* Resolve slide transition settings with cascade:
* Element override -> Page transition defaults -> Hardcoded fallback
*
* @param pageTransition - Resolved page transition (from useTransitionSettings)
* @param elementOverride - Element-level override (optional)
* @returns Final resolved settings for slide transition
*/
export function resolveSlideTransition(
pageTransition: ResolvedTransitionSettings | null | undefined,
elementOverride?: SlideTransitionOverride | null,
): ResolvedSlideTransition {
// Fallback values if no page transition available
const fallback: ResolvedSlideTransition = {
type: 'fade',
durationMs: 700,
easing: 'ease-in-out',
overlayColor: '#000000',
};
// Helper to check if a value is a non-empty string
const hasValue = <T extends string>(
val: T | '' | undefined,
): val is Exclude<T, ''> => Boolean(val);
if (!pageTransition) {
// No page transition settings - use element override or fallback
return {
type: hasValue(elementOverride?.type)
? elementOverride.type
: fallback.type,
durationMs:
typeof elementOverride?.durationMs === 'number' &&
elementOverride.durationMs > 0
? elementOverride.durationMs
: fallback.durationMs,
easing: hasValue(elementOverride?.easing)
? elementOverride.easing
: fallback.easing,
overlayColor:
elementOverride?.overlayColor && elementOverride.overlayColor !== ''
? elementOverride.overlayColor
: fallback.overlayColor,
};
}
// Cascade: Element override -> Page transition
// Type: 'video' maps to 'fade' for slides
let type: SlideTransitionType = 'fade';
if (hasValue(elementOverride?.type)) {
type = elementOverride.type;
} else if (pageTransition.type === 'none') {
type = 'none';
} else {
type = 'fade'; // 'fade' or 'video' -> 'fade'
}
// Duration: Element override -> Page transition
const durationMs =
typeof elementOverride?.durationMs === 'number' &&
elementOverride.durationMs > 0
? elementOverride.durationMs
: pageTransition.durationMs;
// Easing: Element override -> Page transition
const easing = hasValue(elementOverride?.easing)
? elementOverride.easing
: pageTransition.easing;
// Overlay color: Element override -> Page transition
const overlayColor =
elementOverride?.overlayColor && elementOverride.overlayColor !== ''
? elementOverride.overlayColor
: pageTransition.overlayColor;
return { type, durationMs, easing, overlayColor };
}
/**
* Extract slide transition override from carousel element
*/
export function extractCarouselSlideOverride(
element: {
carouselSlideTransitionType?: SlideTransitionType | '';
carouselSlideTransitionDurationMs?: number | '';
carouselSlideTransitionEasing?: EasingFunction | '';
carouselSlideTransitionOverlayColor?: string;
} | null | undefined,
): SlideTransitionOverride | null {
if (!element) return null;
return {
type: element.carouselSlideTransitionType,
durationMs: element.carouselSlideTransitionDurationMs,
easing: element.carouselSlideTransitionEasing,
overlayColor: element.carouselSlideTransitionOverlayColor,
};
}
/**
* Extract slide transition override from gallery element
*/
export function extractGallerySlideOverride(
element: {
gallerySlideTransitionType?: SlideTransitionType | '';
gallerySlideTransitionDurationMs?: number | '';
gallerySlideTransitionEasing?: EasingFunction | '';
gallerySlideTransitionOverlayColor?: string;
} | null | undefined,
): SlideTransitionOverride | null {
if (!element) return null;
return {
type: element.gallerySlideTransitionType,
durationMs: element.gallerySlideTransitionDurationMs,
easing: element.gallerySlideTransitionEasing,
overlayColor: element.gallerySlideTransitionOverlayColor,
};
}

View File

@ -284,7 +284,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
'general' | 'css' | 'effects'
>('general');
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
element: CanvasElement;
elementId: string;
initialIndex: number;
} | null>(null);
// Track background ready state for smooth video transition completion
@ -344,6 +344,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
[elements],
);
// Look up current element for gallery carousel (so it receives updates from element editor)
const activeGalleryCarouselElement = useMemo(() => {
if (!activeGalleryCarousel) return null;
return elements.find((el) => el.id === activeGalleryCarousel.elementId) || null;
}, [activeGalleryCarousel, elements]);
// Draggable panels using useDraggable hook
const {
position: constructorControlsPosition,
@ -1297,7 +1303,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const handleGalleryCardClick = useCallback(
(element: CanvasElement, cardIndex: number) => {
if (element.galleryCards && element.galleryCards.length > 0) {
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
setActiveGalleryCarousel({ elementId: element.id, initialIndex: cardIndex });
}
},
[],
@ -1319,18 +1325,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// because the gallery element may not be selected when the carousel is open
setElements((prev) =>
prev.map((el) =>
el.id === activeGalleryCarousel.element.id
el.id === activeGalleryCarousel.elementId
? { ...el, ...positionPatch }
: el,
),
);
// Update the active carousel element to reflect the new positions
setActiveGalleryCarousel((prev) =>
prev
? { ...prev, element: { ...prev.element, ...positionPatch } }
: null,
);
// No need to update activeGalleryCarousel - it stores only elementId
// and the element lookup is done via activeGalleryCarouselElement useMemo
},
[activeGalleryCarousel, setElements],
);
@ -1764,6 +1765,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
)
}
letterboxStyles={letterboxStyles}
pageTransitionSettings={transitionSettings}
/>
);
})
@ -1831,33 +1833,35 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/>
{/* Gallery Carousel Overlay */}
{activeGalleryCarousel && (
{activeGalleryCarousel && activeGalleryCarouselElement && (
<GalleryCarouselOverlay
cards={activeGalleryCarousel.element.galleryCards || []}
cards={activeGalleryCarouselElement.galleryCards || []}
initialIndex={activeGalleryCarousel.initialIndex}
onClose={() => setActiveGalleryCarousel(null)}
resolveUrl={resolveUrlWithBlob}
prevIconUrl={activeGalleryCarousel.element.galleryCarouselPrevIconUrl}
nextIconUrl={activeGalleryCarousel.element.galleryCarouselNextIconUrl}
backIconUrl={activeGalleryCarousel.element.galleryCarouselBackIconUrl}
prevIconUrl={activeGalleryCarouselElement.galleryCarouselPrevIconUrl}
nextIconUrl={activeGalleryCarouselElement.galleryCarouselNextIconUrl}
backIconUrl={activeGalleryCarouselElement.galleryCarouselBackIconUrl}
backLabel={
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
activeGalleryCarouselElement.galleryCarouselBackLabel || 'BACK'
}
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
backX={activeGalleryCarousel.element.galleryCarouselBackX}
backY={activeGalleryCarousel.element.galleryCarouselBackY}
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
prevHeight={activeGalleryCarousel.element.galleryCarouselPrevHeight}
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight}
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
prevX={activeGalleryCarouselElement.galleryCarouselPrevX}
prevY={activeGalleryCarouselElement.galleryCarouselPrevY}
nextX={activeGalleryCarouselElement.galleryCarouselNextX}
nextY={activeGalleryCarouselElement.galleryCarouselNextY}
backX={activeGalleryCarouselElement.galleryCarouselBackX}
backY={activeGalleryCarouselElement.galleryCarouselBackY}
prevWidth={activeGalleryCarouselElement.galleryCarouselPrevWidth}
prevHeight={activeGalleryCarouselElement.galleryCarouselPrevHeight}
nextWidth={activeGalleryCarouselElement.galleryCarouselNextWidth}
nextHeight={activeGalleryCarouselElement.galleryCarouselNextHeight}
backWidth={activeGalleryCarouselElement.galleryCarouselBackWidth}
backHeight={activeGalleryCarouselElement.galleryCarouselBackHeight}
letterboxStyles={letterboxStyles}
isEditMode={isConstructorEditMode}
onButtonPositionChange={handleGalleryCarouselButtonPositionChange}
pageTransitionSettings={transitionSettings}
galleryElement={activeGalleryCarouselElement}
/>
)}

View File

@ -42,6 +42,7 @@ function isAxiosError(error: unknown): error is AxiosError<ApiError> {
}
// Fetch singleton thunk
// Backend handles public access - no special headers needed
export const fetch = createAsyncThunk<
GlobalTransitionDefaults,
void,

View File

@ -54,6 +54,7 @@ function buildKey(projectId: string, environment: string): string {
/**
* Fetch settings for a specific project and environment
* Backend handles public access based on URL path - no special headers needed
*/
export const fetchByProjectAndEnv = createAsyncThunk<
{ key: string; data: ProjectTransitionSettingsEntity | null },
@ -64,15 +65,12 @@ export const fetchByProjectAndEnv = createAsyncThunk<
async ({ projectId, environment }, { rejectWithValue }) => {
const key = buildKey(projectId, environment);
try {
const result = await axios.get<ProjectTransitionSettingsEntity>(
const result = await axios.get<ProjectTransitionSettingsEntity | null>(
`project-transition-settings/project/${projectId}/env/${environment}`,
);
// API returns null if no settings exist (use global defaults)
return { key, data: result.data };
} catch (error) {
if (isAxiosError(error) && error.response?.status === 404) {
// No settings found - not an error, just means use global defaults
return { key, data: null };
}
if (isAxiosError(error) && error.response) {
return rejectWithValue(error.response.data as ApiError);
}

View File

@ -204,6 +204,18 @@ export interface CanvasElement extends BaseCanvasElement {
carouselPrevHeight?: string;
carouselNextWidth?: string;
carouselNextHeight?: string;
// ═══════════════════════════════════════════════════════════════════
// Carousel slide transition override
// Inherits from page transitions (global → project) if not set
// ═══════════════════════════════════════════════════════════════════
/** Override transition type for carousel slides ('fade' | 'none' | '' for default) */
carouselSlideTransitionType?: 'fade' | 'none' | '';
/** Override transition duration in ms (number or '' for default) */
carouselSlideTransitionDurationMs?: number | '';
/** Override transition easing function */
carouselSlideTransitionEasing?: EasingFunction | '';
/** Override overlay color for slide transitions */
carouselSlideTransitionOverlayColor?: string;
tooltipTitle?: string;
tooltipText?: string;
tooltipTitleFontFamily?: string;
@ -261,6 +273,18 @@ export interface CanvasElement extends BaseCanvasElement {
galleryCarouselNextHeight?: string;
galleryCarouselBackWidth?: string;
galleryCarouselBackHeight?: string;
// ═══════════════════════════════════════════════════════════════════
// Gallery carousel overlay slide transition override
// Inherits from page transitions (global → project) if not set
// ═══════════════════════════════════════════════════════════════════
/** Override transition type for gallery slides ('fade' | 'none' | '' for default) */
gallerySlideTransitionType?: 'fade' | 'none' | '';
/** Override transition duration in ms (number or '' for default) */
gallerySlideTransitionDurationMs?: number | '';
/** Override transition easing function */
gallerySlideTransitionEasing?: EasingFunction | '';
/** Override overlay color for slide transitions */
gallerySlideTransitionOverlayColor?: string;
}
/**

View File

@ -11,6 +11,9 @@ import { BaseEntity } from './entities';
// Simplified: removed 'slide-left', 'slide-right', 'zoom' - only fade/none/video remain
export type TransitionType = 'fade' | 'none' | 'video';
// Slide transition type (subset - no 'video' for slides)
export type SlideTransitionType = 'fade' | 'none';
// Easing function options
export type EasingFunction = 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';