added fade effects to gallery and for carousel slides.
This commit is contained in:
parent
e855db03d1
commit
e4dd94f478
@ -200,7 +200,7 @@ const mountRuntimeEntityRoute = (path, entityName, router) => {
|
|||||||
app.use(
|
app.use(
|
||||||
path,
|
path,
|
||||||
requireRuntimeReadOrAuth,
|
requireRuntimeReadOrAuth,
|
||||||
blockNonPublicRuntimeListEndpoints,
|
blockNonPublicRuntimeListEndpoints(entityName),
|
||||||
sanitizePublicRuntimeListResponse(entityName),
|
sanitizePublicRuntimeListResponse(entityName),
|
||||||
router,
|
router,
|
||||||
);
|
);
|
||||||
@ -237,18 +237,11 @@ app.use(
|
|||||||
jwtAuth,
|
jwtAuth,
|
||||||
project_element_defaultsRoutes,
|
project_element_defaultsRoutes,
|
||||||
);
|
);
|
||||||
app.use(
|
// Global transition defaults - routes handle their own auth (GET public, PUT protected)
|
||||||
'/api/global-transition-defaults',
|
app.use('/api/global-transition-defaults', global_transition_defaultsRoutes);
|
||||||
jwtAuth,
|
|
||||||
global_transition_defaultsRoutes,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Environment-aware project transition settings (supports runtime public access)
|
// Project transition settings - routes handle their own auth (production GET public, else protected)
|
||||||
mountRuntimeEntityRoute(
|
app.use('/api/project-transition-settings', project_transition_settingsRoutes);
|
||||||
'/api/project-transition-settings',
|
|
||||||
'project_transition_settings',
|
|
||||||
project_transition_settingsRoutes,
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use('/api/publish', jwtAuth, publishRoutes);
|
app.use('/api/publish', jwtAuth, publishRoutes);
|
||||||
|
|
||||||
|
|||||||
@ -161,6 +161,8 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
|
|||||||
'PAGE_LINKS',
|
'PAGE_LINKS',
|
||||||
'TRANSITIONS',
|
'TRANSITIONS',
|
||||||
'PROJECT_AUDIO_TRACKS',
|
'PROJECT_AUDIO_TRACKS',
|
||||||
|
'GLOBAL_TRANSITION_DEFAULTS',
|
||||||
|
'PROJECT_TRANSITION_SETTINGS',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
|
|
||||||
|
|
||||||
const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
||||||
projects: [
|
projects: [
|
||||||
'id',
|
'id',
|
||||||
@ -38,6 +36,31 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
|||||||
'sort_order',
|
'sort_order',
|
||||||
'is_enabled',
|
'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) => {
|
const pickFields = (record, fields) => {
|
||||||
@ -61,12 +84,17 @@ const isPublicRuntimeReadRequest = (req) => {
|
|||||||
return req.isRuntimePublicRequest === true && req.method === 'GET';
|
return req.isRuntimePublicRequest === true && req.method === 'GET';
|
||||||
};
|
};
|
||||||
|
|
||||||
const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
|
const blockNonPublicRuntimeListEndpoints = (entityName) => (req, res, next) => {
|
||||||
if (!isPublicRuntimeReadRequest(req)) {
|
if (!isPublicRuntimeReadRequest(req)) {
|
||||||
return next();
|
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' });
|
return res.status(404).send({ message: 'Not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,13 +107,14 @@ const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
|
|||||||
|
|
||||||
const sanitizePublicRuntimeListResponse = (entityName) => {
|
const sanitizePublicRuntimeListResponse = (entityName) => {
|
||||||
const fields = PUBLIC_RUNTIME_ENTITY_FIELDS[entityName] || [];
|
const fields = PUBLIC_RUNTIME_ENTITY_FIELDS[entityName] || [];
|
||||||
|
const allowedPaths = PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] || ['/'];
|
||||||
|
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (
|
const pathMatches = allowedPaths.some((pattern) =>
|
||||||
!isPublicRuntimeReadRequest(req) ||
|
pattern instanceof RegExp ? pattern.test(req.path) : req.path === pattern,
|
||||||
req.path !== PUBLIC_RUNTIME_ALLOWED_PATH ||
|
);
|
||||||
fields.length === 0
|
|
||||||
) {
|
if (!isPublicRuntimeReadRequest(req) || !pathMatches || fields.length === 0) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,16 +125,21 @@ const sanitizePublicRuntimeListResponse = (entityName) => {
|
|||||||
return originalSend(body);
|
return originalSend(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(body.rows)) {
|
// Handle list responses with rows array
|
||||||
return originalSend(body);
|
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({
|
return originalSend(body);
|
||||||
...body,
|
|
||||||
rows: sanitizedRows,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@ -1,13 +1,26 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const passport = require('passport');
|
||||||
const Global_transition_defaultsService = require('../services/global_transition_defaults');
|
const Global_transition_defaultsService = require('../services/global_transition_defaults');
|
||||||
const Global_transition_defaultsDBApi = require('../db/api/global_transition_defaults');
|
const Global_transition_defaultsDBApi = require('../db/api/global_transition_defaults');
|
||||||
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
||||||
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
const router = express.Router();
|
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
|
* @swagger
|
||||||
@ -15,14 +28,14 @@ router.use(checkCrudPermissions('page_elements'));
|
|||||||
* get:
|
* get:
|
||||||
* summary: Get global transition defaults (singleton)
|
* summary: Get global transition defaults (singleton)
|
||||||
* tags: [GlobalTransitionDefaults]
|
* tags: [GlobalTransitionDefaults]
|
||||||
* security:
|
* description: Publicly accessible - no authentication required.
|
||||||
* - bearerAuth: []
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Global transition defaults settings
|
* description: Global transition defaults settings
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
|
allowPublicRead,
|
||||||
wrapAsync(async (_req, res) => {
|
wrapAsync(async (_req, res) => {
|
||||||
const payload = await Global_transition_defaultsDBApi.findOne();
|
const payload = await Global_transition_defaultsDBApi.findOne();
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
@ -35,8 +48,7 @@ router.get(
|
|||||||
* get:
|
* get:
|
||||||
* summary: Get global transition defaults by ID
|
* summary: Get global transition defaults by ID
|
||||||
* tags: [GlobalTransitionDefaults]
|
* tags: [GlobalTransitionDefaults]
|
||||||
* security:
|
* description: Publicly accessible - no authentication required.
|
||||||
* - bearerAuth: []
|
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: id
|
* name: id
|
||||||
@ -50,6 +62,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
allowPublicRead,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isUuidV4(req.params.id)) {
|
if (!isUuidV4(req.params.id)) {
|
||||||
return res.status(400).send('Invalid global_transition_defaults id');
|
return res.status(400).send('Invalid global_transition_defaults id');
|
||||||
@ -102,6 +115,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
jwtAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
await Global_transition_defaultsService.update(
|
await Global_transition_defaultsService.update(
|
||||||
req.body.data,
|
req.body.data,
|
||||||
|
|||||||
@ -1,13 +1,34 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const passport = require('passport');
|
||||||
const Project_transition_settingsService = require('../services/project_transition_settings');
|
const Project_transition_settingsService = require('../services/project_transition_settings');
|
||||||
const Project_transition_settingsDBApi = require('../db/api/project_transition_settings');
|
const Project_transition_settingsDBApi = require('../db/api/project_transition_settings');
|
||||||
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
||||||
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
const router = express.Router();
|
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
|
* @swagger
|
||||||
@ -22,8 +43,7 @@ router.use(checkCrudPermissions('page_elements'));
|
|||||||
* get:
|
* get:
|
||||||
* summary: Get transition settings for a project in a specific environment
|
* summary: Get transition settings for a project in a specific environment
|
||||||
* tags: [Project_transition_settings]
|
* tags: [Project_transition_settings]
|
||||||
* security:
|
* description: Production environment is publicly accessible. Dev/stage require authentication.
|
||||||
* - bearerAuth: []
|
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: projectId
|
* name: projectId
|
||||||
@ -39,12 +59,13 @@ router.use(checkCrudPermissions('page_elements'));
|
|||||||
* enum: [dev, stage, production]
|
* enum: [dev, stage, production]
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Transition settings for the project/environment
|
* description: Transition settings for the project/environment (null if none exist)
|
||||||
* 404:
|
* 401:
|
||||||
* description: No settings found (use global defaults)
|
* description: Authentication required (for dev/stage environments)
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
|
requireProductionOrAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const { projectId, environment } = req.params;
|
const { projectId, environment } = req.params;
|
||||||
|
|
||||||
@ -63,10 +84,7 @@ router.get(
|
|||||||
req.currentUser,
|
req.currentUser,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!settings) {
|
// Return null if no settings exist (frontend will use global defaults)
|
||||||
return res.status(404).send({ message: 'No project-specific settings found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).send(settings);
|
res.status(200).send(settings);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -119,6 +137,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
|
jwtAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const { projectId, environment } = req.params;
|
const { projectId, environment } = req.params;
|
||||||
|
|
||||||
@ -168,6 +187,7 @@ router.put(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
|
jwtAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const { projectId, environment } = req.params;
|
const { projectId, environment } = req.params;
|
||||||
|
|
||||||
@ -211,6 +231,7 @@ router.delete(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
|
jwtAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const payload = await Project_transition_settingsDBApi.findAll(req.query);
|
const payload = await Project_transition_settingsDBApi.findAll(req.query);
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
@ -240,6 +261,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
|
jwtAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const payload = await Project_transition_settingsService.create(
|
const payload = await Project_transition_settingsService.create(
|
||||||
req.body.data,
|
req.body.data,
|
||||||
@ -270,6 +292,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
jwtAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isUuidV4(req.params.id)) {
|
if (!isUuidV4(req.params.id)) {
|
||||||
return res.status(400).send({ message: 'Invalid ID' });
|
return res.status(400).send({ message: 'Invalid ID' });
|
||||||
@ -312,6 +335,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
jwtAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
await Project_transition_settingsService.update(
|
await Project_transition_settingsService.update(
|
||||||
req.body.data,
|
req.body.data,
|
||||||
@ -343,6 +367,7 @@ router.put(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
jwtAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
await Project_transition_settingsService.remove(
|
await Project_transition_settingsService.remove(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
type ElementEffectProperties,
|
type ElementEffectProperties,
|
||||||
} from '../../lib/elementEffects';
|
} from '../../lib/elementEffects';
|
||||||
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
|
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
|
||||||
|
import type { ResolvedTransitionSettings } from '../../types/transition';
|
||||||
|
|
||||||
interface CanvasElementProps {
|
interface CanvasElementProps {
|
||||||
element: CanvasElementType;
|
element: CanvasElementType;
|
||||||
@ -37,6 +38,8 @@ interface CanvasElementProps {
|
|||||||
) => void;
|
) => void;
|
||||||
/** Letterbox styles for constraining fullscreen elements to canvas bounds */
|
/** Letterbox styles for constraining fullscreen elements to canvas bounds */
|
||||||
letterboxStyles?: React.CSSProperties;
|
letterboxStyles?: React.CSSProperties;
|
||||||
|
/** Page transition settings (for slide transition cascade in carousel/gallery) */
|
||||||
|
pageTransitionSettings?: ResolvedTransitionSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CanvasElement: React.FC<CanvasElementProps> = ({
|
const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||||
@ -50,6 +53,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
onGalleryCardClick,
|
onGalleryCardClick,
|
||||||
onCarouselButtonPositionChange,
|
onCarouselButtonPositionChange,
|
||||||
letterboxStyles,
|
letterboxStyles,
|
||||||
|
pageTransitionSettings,
|
||||||
}) => {
|
}) => {
|
||||||
// Extract effect properties from element
|
// Extract effect properties from element
|
||||||
const effectProperties: Partial<ElementEffectProperties> = {
|
const effectProperties: Partial<ElementEffectProperties> = {
|
||||||
@ -140,6 +144,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
onGalleryCardClick={onGalleryCardClick}
|
onGalleryCardClick={onGalleryCardClick}
|
||||||
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
|
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
|
pageTransitionSettings={pageTransitionSettings}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -741,6 +741,7 @@ export function ElementEditorPanel({
|
|||||||
{/* Effects Tab */}
|
{/* Effects Tab */}
|
||||||
{activeTab === 'effects' && (
|
{activeTab === 'effects' && (
|
||||||
<EffectsSettingsSectionCompact
|
<EffectsSettingsSectionCompact
|
||||||
|
elementType={selectedElement.type}
|
||||||
values={{
|
values={{
|
||||||
appearAnimation: selectedElement.appearAnimation || '',
|
appearAnimation: selectedElement.appearAnimation || '',
|
||||||
appearAnimationDuration:
|
appearAnimationDuration:
|
||||||
@ -763,11 +764,104 @@ export function ElementEditorPanel({
|
|||||||
activeOpacity: selectedElement.activeOpacity || '',
|
activeOpacity: selectedElement.activeOpacity || '',
|
||||||
activeBackgroundColor:
|
activeBackgroundColor:
|
||||||
selectedElement.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) => {
|
onChange={(prop, value) => {
|
||||||
updateSelectedElement({
|
// Handle slide transition properties with proper prefixes
|
||||||
[prop]: value || undefined,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -6,12 +6,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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> = ({
|
interface EffectsSettingsSectionCompactProps {
|
||||||
values,
|
values: EffectsSettingsFormValues;
|
||||||
onChange,
|
onChange: (prop: keyof EffectsSettingsFormValues, value: string) => void;
|
||||||
}) => {
|
elementType?: CanvasElementType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EffectsSettingsSectionCompact: React.FC<
|
||||||
|
EffectsSettingsSectionCompactProps
|
||||||
|
> = ({ values, onChange, elementType }) => {
|
||||||
|
const showSlideTransition =
|
||||||
|
elementType === 'gallery' || elementType === 'carousel';
|
||||||
return (
|
return (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{/* Appear Animation */}
|
{/* Appear Animation */}
|
||||||
@ -243,6 +251,99 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -53,6 +53,12 @@ export interface EffectsSettingsFormValues {
|
|||||||
activeScale?: string;
|
activeScale?: string;
|
||||||
activeOpacity?: string;
|
activeOpacity?: string;
|
||||||
activeBackgroundColor?: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
import { isNavigationElementType } from '../lib/elementDefaults';
|
import { isNavigationElementType } from '../lib/elementDefaults';
|
||||||
import { isBackNavigation } from '../lib/navigationHelpers';
|
import { isBackNavigation } from '../lib/navigationHelpers';
|
||||||
import type { CanvasElement } from '../types/constructor';
|
import type { CanvasElement } from '../types/constructor';
|
||||||
|
import type { ResolvedTransitionSettings } from '../types/transition';
|
||||||
|
|
||||||
interface RuntimeElementProps {
|
interface RuntimeElementProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
@ -30,6 +31,8 @@ interface RuntimeElementProps {
|
|||||||
letterboxStyles?: React.CSSProperties;
|
letterboxStyles?: React.CSSProperties;
|
||||||
/** Whether forward navigation is disabled (neighbor pages not yet preloaded) */
|
/** Whether forward navigation is disabled (neighbor pages not yet preloaded) */
|
||||||
isForwardNavDisabled?: boolean;
|
isForwardNavDisabled?: boolean;
|
||||||
|
/** Page transition settings (for slide transition cascade in carousel/gallery) */
|
||||||
|
pageTransitionSettings?: ResolvedTransitionSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp position to canvas bounds (0-100%)
|
// Clamp position to canvas bounds (0-100%)
|
||||||
@ -43,6 +46,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
|||||||
onGalleryCardClick,
|
onGalleryCardClick,
|
||||||
letterboxStyles,
|
letterboxStyles,
|
||||||
isForwardNavDisabled = false,
|
isForwardNavDisabled = false,
|
||||||
|
pageTransitionSettings,
|
||||||
}) => {
|
}) => {
|
||||||
// Clamp coordinates to canvas bounds
|
// Clamp coordinates to canvas bounds
|
||||||
const xPercent = clamp(element.xPercent ?? 50, 0, 100);
|
const xPercent = clamp(element.xPercent ?? 50, 0, 100);
|
||||||
@ -124,6 +128,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
|||||||
onGalleryCardClick={onGalleryCardClick}
|
onGalleryCardClick={onGalleryCardClick}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
pageTransitionSettings={pageTransitionSettings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchGlobalTransitionDefaults());
|
dispatch(fetchGlobalTransitionDefaults());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
@ -796,6 +796,7 @@ export default function RuntimePresentation({
|
|||||||
}
|
}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
isForwardNavDisabled={isForwardNavDisabled}
|
isForwardNavDisabled={isForwardNavDisabled}
|
||||||
|
pageTransitionSettings={transitionSettings}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -879,6 +880,8 @@ export default function RuntimePresentation({
|
|||||||
}
|
}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
isEditMode={false}
|
isEditMode={false}
|
||||||
|
pageTransitionSettings={transitionSettings}
|
||||||
|
galleryElement={activeGalleryCarousel.element}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</BackdropPortalProvider>
|
</BackdropPortalProvider>
|
||||||
|
|||||||
@ -9,8 +9,14 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import Icon from '@mdi/react';
|
import Icon from '@mdi/react';
|
||||||
import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js';
|
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 { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
|
||||||
|
import {
|
||||||
|
resolveSlideTransition,
|
||||||
|
extractGallerySlideOverride,
|
||||||
|
} from '../../lib/resolveSlideTransition';
|
||||||
|
import { useSlideTransition } from '../../hooks/useSlideTransition';
|
||||||
|
|
||||||
interface GalleryCarouselOverlayProps {
|
interface GalleryCarouselOverlayProps {
|
||||||
cards: GalleryCard[];
|
cards: GalleryCard[];
|
||||||
@ -46,6 +52,10 @@ interface GalleryCarouselOverlayProps {
|
|||||||
) => void;
|
) => void;
|
||||||
// Letterbox styles for constraining overlay to canvas bounds
|
// Letterbox styles for constraining overlay to canvas bounds
|
||||||
letterboxStyles?: React.CSSProperties;
|
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) =>
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
@ -75,9 +85,33 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
|||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
onButtonPositionChange,
|
onButtonPositionChange,
|
||||||
letterboxStyles,
|
letterboxStyles,
|
||||||
|
pageTransitionSettings,
|
||||||
|
galleryElement,
|
||||||
}) => {
|
}) => {
|
||||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
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<
|
const [draggingButton, setDraggingButton] = useState<
|
||||||
'prev' | 'next' | 'back' | null
|
'prev' | 'next' | 'back' | null
|
||||||
>(null);
|
>(null);
|
||||||
@ -111,13 +145,15 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
|||||||
// Navigation handlers
|
// Navigation handlers
|
||||||
const goToPrev = useCallback(() => {
|
const goToPrev = useCallback(() => {
|
||||||
if (cards.length === 0) return;
|
if (cards.length === 0) return;
|
||||||
setCurrentIndex((prev) => (prev - 1 + cards.length) % cards.length);
|
const newIndex = (displayIndex - 1 + cards.length) % cards.length;
|
||||||
}, [cards.length]);
|
goToIndex(newIndex);
|
||||||
|
}, [cards.length, displayIndex, goToIndex]);
|
||||||
|
|
||||||
const goToNext = useCallback(() => {
|
const goToNext = useCallback(() => {
|
||||||
if (cards.length === 0) return;
|
if (cards.length === 0) return;
|
||||||
setCurrentIndex((prev) => (prev + 1) % cards.length);
|
const newIndex = (displayIndex + 1) % cards.length;
|
||||||
}, [cards.length]);
|
goToIndex(newIndex);
|
||||||
|
}, [cards.length, displayIndex, goToIndex]);
|
||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
useEffect(() => {
|
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) : '';
|
const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -377,9 +413,21 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
|||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={currentCard?.title || ''}
|
alt={currentCard?.title || ''}
|
||||||
className='absolute inset-0 h-full w-full object-contain'
|
className='absolute inset-0 h-full w-full object-contain'
|
||||||
|
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
|
||||||
draggable={false}
|
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 */}
|
{/* Prev button */}
|
||||||
{renderNavButton(
|
{renderNavButton(
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { CanvasElement } from '../../types/constructor';
|
import type { CanvasElement } from '../../types/constructor';
|
||||||
|
import type { ResolvedTransitionSettings } from '../../types/transition';
|
||||||
import { useElementWrapperStyle } from './shared/useElementWrapperStyle';
|
import { useElementWrapperStyle } from './shared/useElementWrapperStyle';
|
||||||
import {
|
import {
|
||||||
isNavigationElementType,
|
isNavigationElementType,
|
||||||
@ -53,6 +54,8 @@ export interface UiElementRendererProps {
|
|||||||
) => void;
|
) => void;
|
||||||
// Letterbox styles for constraining fullscreen elements to canvas bounds
|
// Letterbox styles for constraining fullscreen elements to canvas bounds
|
||||||
letterboxStyles?: React.CSSProperties;
|
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,
|
onGalleryCardClick,
|
||||||
onCarouselButtonPositionChange,
|
onCarouselButtonPositionChange,
|
||||||
letterboxStyles,
|
letterboxStyles,
|
||||||
|
pageTransitionSettings,
|
||||||
}) => {
|
}) => {
|
||||||
const { className, style } = useElementWrapperStyle({
|
const { className, style } = useElementWrapperStyle({
|
||||||
element,
|
element,
|
||||||
@ -101,6 +105,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
|||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
onButtonPositionChange={onCarouselButtonPositionChange}
|
onButtonPositionChange={onCarouselButtonPositionChange}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
|
pageTransitionSettings={pageTransitionSettings}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,21 +14,21 @@
|
|||||||
* - Navigation-style rendering when custom icons with dimensions are set
|
* - Navigation-style rendering when custom icons with dimensions are set
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {
|
import React, { useMemo, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
useState,
|
|
||||||
useMemo,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
} from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import Icon from '@mdi/react';
|
import Icon from '@mdi/react';
|
||||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||||
import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
|
import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
|
||||||
|
import type { ResolvedTransitionSettings } from '../../../types/transition';
|
||||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||||
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
|
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
|
||||||
import { toCU } from '../../../lib/canvasScale';
|
import { toCU } from '../../../lib/canvasScale';
|
||||||
|
import {
|
||||||
|
resolveSlideTransition,
|
||||||
|
extractCarouselSlideOverride,
|
||||||
|
} from '../../../lib/resolveSlideTransition';
|
||||||
|
import { useSlideTransition } from '../../../hooks/useSlideTransition';
|
||||||
|
|
||||||
interface CarouselElementProps {
|
interface CarouselElementProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
@ -44,6 +44,8 @@ interface CarouselElementProps {
|
|||||||
) => void;
|
) => void;
|
||||||
// Letterbox styles for constraining full-width carousel to canvas bounds
|
// Letterbox styles for constraining full-width carousel to canvas bounds
|
||||||
letterboxStyles?: CSSProperties;
|
letterboxStyles?: CSSProperties;
|
||||||
|
// Page transition settings (for slide transition cascade)
|
||||||
|
pageTransitionSettings?: ResolvedTransitionSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number) =>
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
@ -57,13 +59,31 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
|||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
onButtonPositionChange,
|
onButtonPositionChange,
|
||||||
letterboxStyles,
|
letterboxStyles,
|
||||||
|
pageTransitionSettings,
|
||||||
}) => {
|
}) => {
|
||||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||||
const slides: CarouselSlide[] = element.carouselSlides || [];
|
const slides: CarouselSlide[] = element.carouselSlides || [];
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
const currentSlide = slides[currentIndex] || slides[0];
|
|
||||||
const isFullWidth = element.carouselFullWidth || false;
|
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)
|
// Drag state (constructor edit mode only)
|
||||||
const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>(
|
const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>(
|
||||||
null,
|
null,
|
||||||
@ -100,13 +120,15 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
|||||||
// Navigation handlers (no event parameter for keyboard/swipe use)
|
// Navigation handlers (no event parameter for keyboard/swipe use)
|
||||||
const goToPrev = useCallback(() => {
|
const goToPrev = useCallback(() => {
|
||||||
if (slides.length === 0) return;
|
if (slides.length === 0) return;
|
||||||
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length);
|
const newIndex = (displayIndex - 1 + slides.length) % slides.length;
|
||||||
}, [slides.length]);
|
goToIndex(newIndex);
|
||||||
|
}, [slides.length, displayIndex, goToIndex]);
|
||||||
|
|
||||||
const goToNext = useCallback(() => {
|
const goToNext = useCallback(() => {
|
||||||
if (slides.length === 0) return;
|
if (slides.length === 0) return;
|
||||||
setCurrentIndex((prev) => (prev + 1) % slides.length);
|
const newIndex = (displayIndex + 1) % slides.length;
|
||||||
}, [slides.length]);
|
goToIndex(newIndex);
|
||||||
|
}, [slides.length, displayIndex, goToIndex]);
|
||||||
|
|
||||||
// Click handlers for buttons (with event propagation control)
|
// Click handlers for buttons (with event propagation control)
|
||||||
const handlePrevClick = useCallback(
|
const handlePrevClick = useCallback(
|
||||||
@ -365,9 +387,21 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
|||||||
src={resolve(currentSlide.imageUrl)}
|
src={resolve(currentSlide.imageUrl)}
|
||||||
alt={currentSlide.caption || 'Carousel slide'}
|
alt={currentSlide.caption || 'Carousel slide'}
|
||||||
className='absolute inset-0 w-full h-full object-contain'
|
className='absolute inset-0 w-full h-full object-contain'
|
||||||
|
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
|
||||||
draggable={false}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -459,7 +493,7 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
|||||||
// Normal mode: inline carousel within element dimensions
|
// Normal mode: inline carousel within element dimensions
|
||||||
return (
|
return (
|
||||||
<div className={className} style={style}>
|
<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 */}
|
{/* Current slide image */}
|
||||||
{currentSlide?.imageUrl && (
|
{currentSlide?.imageUrl && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@ -467,9 +501,21 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
|||||||
src={resolve(currentSlide.imageUrl)}
|
src={resolve(currentSlide.imageUrl)}
|
||||||
alt={currentSlide.caption || 'Carousel slide'}
|
alt={currentSlide.caption || 'Carousel slide'}
|
||||||
className='w-full h-full object-cover rounded'
|
className='w-full h-full object-cover rounded'
|
||||||
|
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Transition overlay */}
|
||||||
|
{slideTransition.type === 'fade' && (
|
||||||
|
<div
|
||||||
|
className='absolute inset-0 pointer-events-none rounded'
|
||||||
|
style={{
|
||||||
|
...overlayTransitionStyle,
|
||||||
|
backgroundColor: overlayColor,
|
||||||
|
opacity: overlayOpacity,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Navigation buttons */}
|
{/* Navigation buttons */}
|
||||||
{showNavigation && (
|
{showNavigation && (
|
||||||
|
|||||||
@ -54,6 +54,7 @@ export type {
|
|||||||
UseTransitionCreationOptions,
|
UseTransitionCreationOptions,
|
||||||
UseTransitionCreationResult,
|
UseTransitionCreationResult,
|
||||||
} from './useTransitionCreation';
|
} from './useTransitionCreation';
|
||||||
|
export { useSlideTransition } from './useSlideTransition';
|
||||||
|
|
||||||
// Constructor hooks - import directly for better tree-shaking:
|
// Constructor hooks - import directly for better tree-shaking:
|
||||||
// import { useOutsideClick } from '../hooks/useOutsideClick';
|
// import { useOutsideClick } from '../hooks/useOutsideClick';
|
||||||
|
|||||||
159
frontend/src/hooks/useSlideTransition.ts
Normal file
159
frontend/src/hooks/useSlideTransition.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
147
frontend/src/lib/resolveSlideTransition.ts
Normal file
147
frontend/src/lib/resolveSlideTransition.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -284,7 +284,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
'general' | 'css' | 'effects'
|
'general' | 'css' | 'effects'
|
||||||
>('general');
|
>('general');
|
||||||
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
|
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
|
||||||
element: CanvasElement;
|
elementId: string;
|
||||||
initialIndex: number;
|
initialIndex: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
// Track background ready state for smooth video transition completion
|
// Track background ready state for smooth video transition completion
|
||||||
@ -344,6 +344,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
[elements],
|
[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
|
// Draggable panels using useDraggable hook
|
||||||
const {
|
const {
|
||||||
position: constructorControlsPosition,
|
position: constructorControlsPosition,
|
||||||
@ -1297,7 +1303,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
const handleGalleryCardClick = useCallback(
|
const handleGalleryCardClick = useCallback(
|
||||||
(element: CanvasElement, cardIndex: number) => {
|
(element: CanvasElement, cardIndex: number) => {
|
||||||
if (element.galleryCards && element.galleryCards.length > 0) {
|
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
|
// because the gallery element may not be selected when the carousel is open
|
||||||
setElements((prev) =>
|
setElements((prev) =>
|
||||||
prev.map((el) =>
|
prev.map((el) =>
|
||||||
el.id === activeGalleryCarousel.element.id
|
el.id === activeGalleryCarousel.elementId
|
||||||
? { ...el, ...positionPatch }
|
? { ...el, ...positionPatch }
|
||||||
: el,
|
: el,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// No need to update activeGalleryCarousel - it stores only elementId
|
||||||
// Update the active carousel element to reflect the new positions
|
// and the element lookup is done via activeGalleryCarouselElement useMemo
|
||||||
setActiveGalleryCarousel((prev) =>
|
|
||||||
prev
|
|
||||||
? { ...prev, element: { ...prev.element, ...positionPatch } }
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[activeGalleryCarousel, setElements],
|
[activeGalleryCarousel, setElements],
|
||||||
);
|
);
|
||||||
@ -1764,6 +1765,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
|
pageTransitionSettings={transitionSettings}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@ -1831,33 +1833,35 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Gallery Carousel Overlay */}
|
{/* Gallery Carousel Overlay */}
|
||||||
{activeGalleryCarousel && (
|
{activeGalleryCarousel && activeGalleryCarouselElement && (
|
||||||
<GalleryCarouselOverlay
|
<GalleryCarouselOverlay
|
||||||
cards={activeGalleryCarousel.element.galleryCards || []}
|
cards={activeGalleryCarouselElement.galleryCards || []}
|
||||||
initialIndex={activeGalleryCarousel.initialIndex}
|
initialIndex={activeGalleryCarousel.initialIndex}
|
||||||
onClose={() => setActiveGalleryCarousel(null)}
|
onClose={() => setActiveGalleryCarousel(null)}
|
||||||
resolveUrl={resolveUrlWithBlob}
|
resolveUrl={resolveUrlWithBlob}
|
||||||
prevIconUrl={activeGalleryCarousel.element.galleryCarouselPrevIconUrl}
|
prevIconUrl={activeGalleryCarouselElement.galleryCarouselPrevIconUrl}
|
||||||
nextIconUrl={activeGalleryCarousel.element.galleryCarouselNextIconUrl}
|
nextIconUrl={activeGalleryCarouselElement.galleryCarouselNextIconUrl}
|
||||||
backIconUrl={activeGalleryCarousel.element.galleryCarouselBackIconUrl}
|
backIconUrl={activeGalleryCarouselElement.galleryCarouselBackIconUrl}
|
||||||
backLabel={
|
backLabel={
|
||||||
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
|
activeGalleryCarouselElement.galleryCarouselBackLabel || 'BACK'
|
||||||
}
|
}
|
||||||
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
|
prevX={activeGalleryCarouselElement.galleryCarouselPrevX}
|
||||||
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
|
prevY={activeGalleryCarouselElement.galleryCarouselPrevY}
|
||||||
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
|
nextX={activeGalleryCarouselElement.galleryCarouselNextX}
|
||||||
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
|
nextY={activeGalleryCarouselElement.galleryCarouselNextY}
|
||||||
backX={activeGalleryCarousel.element.galleryCarouselBackX}
|
backX={activeGalleryCarouselElement.galleryCarouselBackX}
|
||||||
backY={activeGalleryCarousel.element.galleryCarouselBackY}
|
backY={activeGalleryCarouselElement.galleryCarouselBackY}
|
||||||
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
|
prevWidth={activeGalleryCarouselElement.galleryCarouselPrevWidth}
|
||||||
prevHeight={activeGalleryCarousel.element.galleryCarouselPrevHeight}
|
prevHeight={activeGalleryCarouselElement.galleryCarouselPrevHeight}
|
||||||
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
|
nextWidth={activeGalleryCarouselElement.galleryCarouselNextWidth}
|
||||||
nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight}
|
nextHeight={activeGalleryCarouselElement.galleryCarouselNextHeight}
|
||||||
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
|
backWidth={activeGalleryCarouselElement.galleryCarouselBackWidth}
|
||||||
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
|
backHeight={activeGalleryCarouselElement.galleryCarouselBackHeight}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
isEditMode={isConstructorEditMode}
|
isEditMode={isConstructorEditMode}
|
||||||
onButtonPositionChange={handleGalleryCarouselButtonPositionChange}
|
onButtonPositionChange={handleGalleryCarouselButtonPositionChange}
|
||||||
|
pageTransitionSettings={transitionSettings}
|
||||||
|
galleryElement={activeGalleryCarouselElement}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,7 @@ function isAxiosError(error: unknown): error is AxiosError<ApiError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch singleton thunk
|
// Fetch singleton thunk
|
||||||
|
// Backend handles public access - no special headers needed
|
||||||
export const fetch = createAsyncThunk<
|
export const fetch = createAsyncThunk<
|
||||||
GlobalTransitionDefaults,
|
GlobalTransitionDefaults,
|
||||||
void,
|
void,
|
||||||
|
|||||||
@ -54,6 +54,7 @@ function buildKey(projectId: string, environment: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch settings for a specific project and environment
|
* Fetch settings for a specific project and environment
|
||||||
|
* Backend handles public access based on URL path - no special headers needed
|
||||||
*/
|
*/
|
||||||
export const fetchByProjectAndEnv = createAsyncThunk<
|
export const fetchByProjectAndEnv = createAsyncThunk<
|
||||||
{ key: string; data: ProjectTransitionSettingsEntity | null },
|
{ key: string; data: ProjectTransitionSettingsEntity | null },
|
||||||
@ -64,15 +65,12 @@ export const fetchByProjectAndEnv = createAsyncThunk<
|
|||||||
async ({ projectId, environment }, { rejectWithValue }) => {
|
async ({ projectId, environment }, { rejectWithValue }) => {
|
||||||
const key = buildKey(projectId, environment);
|
const key = buildKey(projectId, environment);
|
||||||
try {
|
try {
|
||||||
const result = await axios.get<ProjectTransitionSettingsEntity>(
|
const result = await axios.get<ProjectTransitionSettingsEntity | null>(
|
||||||
`project-transition-settings/project/${projectId}/env/${environment}`,
|
`project-transition-settings/project/${projectId}/env/${environment}`,
|
||||||
);
|
);
|
||||||
|
// API returns null if no settings exist (use global defaults)
|
||||||
return { key, data: result.data };
|
return { key, data: result.data };
|
||||||
} catch (error) {
|
} 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) {
|
if (isAxiosError(error) && error.response) {
|
||||||
return rejectWithValue(error.response.data as ApiError);
|
return rejectWithValue(error.response.data as ApiError);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -204,6 +204,18 @@ export interface CanvasElement extends BaseCanvasElement {
|
|||||||
carouselPrevHeight?: string;
|
carouselPrevHeight?: string;
|
||||||
carouselNextWidth?: string;
|
carouselNextWidth?: string;
|
||||||
carouselNextHeight?: 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;
|
tooltipTitle?: string;
|
||||||
tooltipText?: string;
|
tooltipText?: string;
|
||||||
tooltipTitleFontFamily?: string;
|
tooltipTitleFontFamily?: string;
|
||||||
@ -261,6 +273,18 @@ export interface CanvasElement extends BaseCanvasElement {
|
|||||||
galleryCarouselNextHeight?: string;
|
galleryCarouselNextHeight?: string;
|
||||||
galleryCarouselBackWidth?: string;
|
galleryCarouselBackWidth?: string;
|
||||||
galleryCarouselBackHeight?: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -11,6 +11,9 @@ import { BaseEntity } from './entities';
|
|||||||
// Simplified: removed 'slide-left', 'slide-right', 'zoom' - only fade/none/video remain
|
// Simplified: removed 'slide-left', 'slide-right', 'zoom' - only fade/none/video remain
|
||||||
export type TransitionType = 'fade' | 'none' | 'video';
|
export type TransitionType = 'fade' | 'none' | 'video';
|
||||||
|
|
||||||
|
// Slide transition type (subset - no 'video' for slides)
|
||||||
|
export type SlideTransitionType = 'fade' | 'none';
|
||||||
|
|
||||||
// Easing function options
|
// Easing function options
|
||||||
export type EasingFunction = 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';
|
export type EasingFunction = 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';
|
||||||
|
|
||||||
|
|||||||
Loadingβ¦
x
Reference in New Issue
Block a user