39948-vm/frontend/src/lib/elementDefaults.ts
2026-05-05 17:25:53 +02:00

729 lines
20 KiB
TypeScript

/**
* Element Defaults
*
* Single source of truth for UI element default values, creation, and merging.
* Used by constructor, runtime, element-type-defaults, and project-element-defaults pages.
*/
import type {
CanvasElement,
CanvasElementType,
GalleryCard,
GalleryInfoSpan,
CarouselSlide,
NavigationButtonKind,
} from '../types/constructor';
import { ELEMENT_STYLE_PROPS } from './elementStyles';
import { GALLERY_SECTION_STYLE_PROPS } from './gallerySectionStyles';
/**
* Effect properties that can be configured in element defaults.
* Used for appear animations, hover effects, focus effects, and active effects.
*/
export const ELEMENT_EFFECT_PROPS = [
'appearAnimation',
'appearAnimationDuration',
'appearAnimationEasing',
'hoverScale',
'hoverOpacity',
'hoverBackgroundColor',
'hoverColor',
'hoverBoxShadow',
'hoverTransitionDuration',
'focusScale',
'focusOpacity',
'focusOutline',
'focusBoxShadow',
'activeScale',
'activeOpacity',
'activeBackgroundColor',
] as const;
/**
* Generate a local unique ID for elements
*/
export const createLocalId = (): string => {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
return `element_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
/**
* Clamp a number between min and max
*/
export const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
/**
* Normalize appearDelaySec value
*/
export const normalizeAppearDelaySec = (value: unknown): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return parsed;
};
/**
* Normalize appearDurationSec value (null means infinite)
*/
export const normalizeAppearDurationSec = (value: unknown): number | null => {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
};
/**
* Labels for each element type
*/
export const ELEMENT_TYPE_LABELS: Record<CanvasElementType, string> = {
navigation_next: 'Navigation: Forward',
navigation_prev: 'Navigation: Back',
spot: 'Hotspot',
description: 'Description',
tooltip: 'Tooltip',
gallery: 'Gallery',
carousel: 'Carousel',
logo: 'Logo',
video_player: 'Video Player',
audio_player: 'Audio Player',
popup: 'Popup',
};
/**
* Get navigation button label based on type
*/
export const getNavigationButtonLabel = (
type: 'navigation_next' | 'navigation_prev',
): string => (type === 'navigation_next' ? 'Forward' : 'Back');
/**
* Get navigation button kind based on type
*/
export const getNavigationButtonKind = (
type: 'navigation_next' | 'navigation_prev',
): NavigationButtonKind => (type === 'navigation_prev' ? 'back' : 'forward');
/**
* Get navigation type from kind
*/
export const getNavigationTypeFromKind = (
kind: NavigationButtonKind,
): 'navigation_next' | 'navigation_prev' =>
kind === 'back' ? 'navigation_prev' : 'navigation_next';
/**
* Type-specific default values for elements.
* These are the base defaults used when creating new elements.
*/
export const TYPE_SPECIFIC_DEFAULTS: Partial<
Record<CanvasElementType, Partial<CanvasElement>>
> = {
navigation_next: {
navLabel: 'Forward',
navType: 'forward',
navDisabled: false,
iconUrl: '',
transitionReverseMode: 'auto_reverse',
},
navigation_prev: {
navLabel: 'Back',
navType: 'back',
navDisabled: false,
iconUrl: '',
transitionReverseMode: 'auto_reverse',
},
tooltip: {
iconUrl: '',
tooltipTitle: 'Tooltip title',
tooltipText: 'Tooltip text',
},
description: {
iconUrl: '',
descriptionTitle: 'TITLE',
descriptionText: '',
descriptionTitleFontSize: '24px',
descriptionTextFontSize: '16px',
descriptionTitleFontFamily: 'inherit',
descriptionTextFontFamily: 'inherit',
descriptionTitleColor: '#ffffff',
descriptionTextColor: '#ffffff',
},
gallery: {
galleryCards: [],
},
carousel: {
carouselSlides: [],
carouselPrevIconUrl: '',
carouselNextIconUrl: '',
},
video_player: {
mediaUrl: '',
mediaAutoplay: true,
mediaLoop: true,
mediaMuted: true,
},
audio_player: {
mediaUrl: '',
mediaAutoplay: true,
mediaLoop: true,
mediaMuted: false,
},
logo: {
iconUrl: '',
},
spot: {
iconUrl: '',
},
popup: {},
};
/**
* Create a default gallery card
*/
export const createDefaultGalleryCard = (index: number): GalleryCard => ({
id: createLocalId(),
imageUrl: '',
title: `Card ${index + 1}`,
description: '',
});
/**
* Create a default carousel slide
*/
export const createDefaultCarouselSlide = (index: number): CarouselSlide => ({
id: createLocalId(),
imageUrl: '',
caption: `Slide ${index + 1}`,
});
/**
* Create a new element with default values.
* The index is used to offset position for multiple elements.
*/
export const createDefaultElement = (
type: CanvasElementType,
index = 0,
): CanvasElement => {
const base: CanvasElement = {
id: createLocalId(),
type,
label: ELEMENT_TYPE_LABELS[type] || type,
xPercent: clamp(45 + index * 3, 10, 85), // Center horizontally
yPercent: clamp(45 + index * 4, 15, 80), // Center vertically
appearDelaySec: 0,
appearDurationSec: null,
};
const typeDefaults = TYPE_SPECIFIC_DEFAULTS[type] || {};
// Handle gallery with initial card
if (isGalleryElementType(type)) {
return {
...base,
...typeDefaults,
galleryCards: [createDefaultGalleryCard(0)],
};
}
// Handle carousel with initial slide
if (isCarouselElementType(type)) {
return {
...base,
...typeDefaults,
carouselSlides: [createDefaultCarouselSlide(0)],
};
}
return {
...base,
...typeDefaults,
};
};
/**
* Normalize a gallery card from unknown input
*/
export const normalizeGalleryCard = (
card: Record<string, unknown>,
): GalleryCard => ({
id: String(card?.id || createLocalId()),
imageUrl: String(card?.imageUrl ?? ''),
title: String(card?.title ?? ''),
description: String(card?.description ?? ''),
});
/**
* Normalize a gallery info span from unknown input
*/
export const normalizeGalleryInfoSpan = (
span: Record<string, unknown>,
): GalleryInfoSpan => ({
id: String(span?.id || createLocalId()),
text: String(span?.text ?? ''),
iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined,
});
/**
* Normalize a carousel slide from unknown input
*/
export const normalizeCarouselSlide = (
slide: Record<string, unknown>,
): CarouselSlide => ({
id: String(slide?.id || createLocalId()),
imageUrl: String(slide?.imageUrl ?? ''),
caption: String(slide?.caption ?? ''),
});
/**
* Merge an element with project/global defaults.
* Used when loading elements from the database or creating new elements.
*
* @param element - The element to merge
* @param defaults - Project or global defaults to apply
* @param options.preferElementValues - If true, element values take precedence over defaults
*/
export const mergeElementWithDefaults = (
element: CanvasElement,
defaults?: Partial<CanvasElement>,
options?: { preferElementValues?: boolean },
): CanvasElement => {
if (!defaults) return element;
const preferElementValues = Boolean(options?.preferElementValues);
const base = preferElementValues ? defaults : element;
const override = preferElementValues ? element : defaults;
const merged: CanvasElement = {
...base,
...override,
id: element.id,
type: element.type,
label: element.label || defaults.label || element.type,
xPercent: element.xPercent ?? defaults.xPercent ?? 50,
yPercent: element.yPercent ?? defaults.yPercent ?? 50,
};
// For style properties, use defaults if element has empty/null/undefined value
// This ensures DB defaults are applied when element has no explicit value
const elementRecord = element as unknown as Record<string, unknown>;
const defaultsRecord = defaults as unknown as Record<string, unknown>;
const mergedRecord = merged as unknown as Record<string, unknown>;
ELEMENT_STYLE_PROPS.forEach((prop) => {
const elementValue = elementRecord[prop];
const defaultValue = defaultsRecord[prop];
const elementIsEmpty =
elementValue === '' ||
elementValue === undefined ||
elementValue === null;
const defaultHasValue =
defaultValue !== undefined &&
defaultValue !== null &&
defaultValue !== '';
if (elementIsEmpty && defaultHasValue) {
mergedRecord[prop] = defaultValue;
}
});
// For effect properties, use defaults if element has empty/null/undefined value
// This ensures effect defaults (animations, hover, focus, active) cascade to elements
ELEMENT_EFFECT_PROPS.forEach((prop) => {
const elementValue = elementRecord[prop];
const defaultValue = defaultsRecord[prop];
const elementIsEmpty =
elementValue === '' ||
elementValue === undefined ||
elementValue === null;
const defaultHasValue =
defaultValue !== undefined &&
defaultValue !== null &&
defaultValue !== '';
if (elementIsEmpty && defaultHasValue) {
mergedRecord[prop] = defaultValue;
}
});
// Normalize position values
merged.xPercent = clamp(Number(merged.xPercent ?? element.xPercent), 0, 100);
merged.yPercent = clamp(Number(merged.yPercent ?? element.yPercent), 0, 100);
merged.appearDelaySec = normalizeAppearDelaySec(merged.appearDelaySec);
merged.appearDurationSec = normalizeAppearDurationSec(
merged.appearDurationSec,
);
// Handle gallery cards array
if (isGalleryElementType(merged.type)) {
const cards = preferElementValues
? Array.isArray(element.galleryCards)
? element.galleryCards
: defaults.galleryCards || []
: Array.isArray(defaults.galleryCards)
? defaults.galleryCards
: element.galleryCards || [];
merged.galleryCards = cards.map((card) =>
normalizeGalleryCard(card as unknown as Record<string, unknown>),
);
// Handle gallery info spans array
const spans = preferElementValues
? Array.isArray(element.galleryInfoSpans)
? element.galleryInfoSpans
: defaults.galleryInfoSpans || []
: Array.isArray(defaults.galleryInfoSpans)
? defaults.galleryInfoSpans
: element.galleryInfoSpans || [];
merged.galleryInfoSpans = spans.map((span) =>
normalizeGalleryInfoSpan(span as unknown as Record<string, unknown>),
);
}
// Handle carousel slides array
if (isCarouselElementType(merged.type)) {
const slides = preferElementValues
? Array.isArray(element.carouselSlides)
? element.carouselSlides
: defaults.carouselSlides || []
: Array.isArray(defaults.carouselSlides)
? defaults.carouselSlides
: element.carouselSlides || [];
merged.carouselSlides = slides.map((slide) =>
normalizeCarouselSlide(slide as unknown as Record<string, unknown>),
);
}
return merged;
};
/**
* Parse element settings from JSON string or object.
* Used when loading element defaults from the database.
*/
export const parseElementSettings = (
settingsValue?: string | Record<string, unknown>,
): Partial<CanvasElement> => {
if (!settingsValue) return {};
let settings: Record<string, unknown> = {};
if (typeof settingsValue === 'string') {
try {
settings = JSON.parse(settingsValue);
} catch {
return {};
}
} else {
settings = settingsValue;
}
// Parse gallery cards if present
if (Array.isArray(settings.galleryCards)) {
settings.galleryCards = settings.galleryCards.map((card) =>
normalizeGalleryCard(card as Record<string, unknown>),
);
}
// Parse gallery info spans if present
if (Array.isArray(settings.galleryInfoSpans)) {
settings.galleryInfoSpans = settings.galleryInfoSpans.map((span) =>
normalizeGalleryInfoSpan(span as Record<string, unknown>),
);
}
// Parse carousel slides if present
if (Array.isArray(settings.carouselSlides)) {
settings.carouselSlides = settings.carouselSlides.map((slide) =>
normalizeCarouselSlide(slide as Record<string, unknown>),
);
}
return settings as Partial<CanvasElement>;
};
/**
* Check if a value is empty (null, undefined, or empty string)
*/
const isEmpty = (value: unknown): boolean =>
value === null || value === undefined || value === '';
/**
* Add value to settings if not empty
*/
const addIfNotEmpty = (
settings: Record<string, unknown>,
key: string,
value: unknown,
): void => {
if (!isEmpty(value)) {
settings[key] = value;
}
};
/**
* Build settings JSON object from element properties.
* Used when saving element defaults to the database.
*/
export const buildElementSettings = (
element: Partial<CanvasElement>,
elementType: CanvasElementType | string,
): Record<string, unknown> => {
const settings: Record<string, unknown> = {};
// Common properties
addIfNotEmpty(settings, 'label', element.label);
if (element.xPercent !== undefined) settings.xPercent = element.xPercent;
if (element.yPercent !== undefined) settings.yPercent = element.yPercent;
if (element.appearDelaySec !== undefined) {
settings.appearDelaySec = normalizeAppearDelaySec(element.appearDelaySec);
}
if (element.appearDurationSec !== undefined) {
settings.appearDurationSec = normalizeAppearDurationSec(
element.appearDurationSec,
);
}
// Style properties
ELEMENT_STYLE_PROPS.forEach((prop) => {
const value = (element as Record<string, unknown>)[prop];
addIfNotEmpty(settings, prop, value);
});
// Effect properties (using shared constant)
ELEMENT_EFFECT_PROPS.forEach((prop) => {
const value = (element as Record<string, unknown>)[prop];
addIfNotEmpty(settings, prop, value);
});
// Navigation type settings
if (isNavigationElementType(elementType)) {
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
addIfNotEmpty(settings, 'navLabel', element.navLabel);
addIfNotEmpty(settings, 'navType', element.navType);
if (element.navDisabled !== undefined) {
settings.navDisabled = element.navDisabled;
}
addIfNotEmpty(settings, 'targetPageId', element.targetPageId);
addIfNotEmpty(settings, 'targetPageSlug', element.targetPageSlug);
addIfNotEmpty(settings, 'transitionVideoUrl', element.transitionVideoUrl);
addIfNotEmpty(
settings,
'transitionReverseMode',
element.transitionReverseMode,
);
addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl);
}
// Tooltip type settings
if (isTooltipElementType(elementType)) {
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
addIfNotEmpty(settings, 'tooltipTitle', element.tooltipTitle);
addIfNotEmpty(settings, 'tooltipText', element.tooltipText);
addIfNotEmpty(
settings,
'tooltipTitleFontFamily',
element.tooltipTitleFontFamily,
);
addIfNotEmpty(
settings,
'tooltipTextFontFamily',
element.tooltipTextFontFamily,
);
}
// Description type settings
if (isDescriptionElementType(elementType)) {
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
addIfNotEmpty(settings, 'descriptionTitle', element.descriptionTitle);
addIfNotEmpty(settings, 'descriptionText', element.descriptionText);
addIfNotEmpty(
settings,
'descriptionTitleFontSize',
element.descriptionTitleFontSize,
);
addIfNotEmpty(
settings,
'descriptionTextFontSize',
element.descriptionTextFontSize,
);
addIfNotEmpty(
settings,
'descriptionTitleFontFamily',
element.descriptionTitleFontFamily,
);
addIfNotEmpty(
settings,
'descriptionTextFontFamily',
element.descriptionTextFontFamily,
);
addIfNotEmpty(
settings,
'descriptionTitleColor',
element.descriptionTitleColor,
);
addIfNotEmpty(
settings,
'descriptionTextColor',
element.descriptionTextColor,
);
}
// Gallery type settings
if (isGalleryElementType(elementType)) {
if (Array.isArray(element.galleryCards)) {
settings.galleryCards = element.galleryCards.map((card) => ({
id: String(card.id || createLocalId()),
imageUrl: card.imageUrl ?? '',
title: card.title ?? '',
description: card.description ?? '',
}));
}
if (Array.isArray(element.galleryInfoSpans)) {
settings.galleryInfoSpans = element.galleryInfoSpans.map((span) => ({
id: String(span.id || createLocalId()),
text: span.text ?? '',
iconUrl: span.iconUrl ?? undefined,
}));
}
addIfNotEmpty(
settings,
'galleryTitleFontFamily',
element.galleryTitleFontFamily,
);
addIfNotEmpty(
settings,
'galleryTextFontFamily',
element.galleryTextFontFamily,
);
addIfNotEmpty(settings, 'galleryColumns', element.galleryColumns);
addIfNotEmpty(
settings,
'galleryHeaderImageUrl',
element.galleryHeaderImageUrl,
);
addIfNotEmpty(settings, 'galleryHeaderText', element.galleryHeaderText);
addIfNotEmpty(settings, 'galleryTitle', element.galleryTitle);
// Gallery section style properties
GALLERY_SECTION_STYLE_PROPS.forEach((prop) => {
const value = (element as Record<string, unknown>)[prop];
addIfNotEmpty(settings, prop, value);
});
}
// Carousel type settings
if (isCarouselElementType(elementType)) {
if (Array.isArray(element.carouselSlides)) {
settings.carouselSlides = element.carouselSlides.map((slide) => ({
id: String(slide.id || createLocalId()),
imageUrl: slide.imageUrl ?? '',
caption: slide.caption ?? '',
}));
}
addIfNotEmpty(settings, 'carouselPrevIconUrl', element.carouselPrevIconUrl);
addIfNotEmpty(settings, 'carouselNextIconUrl', element.carouselNextIconUrl);
addIfNotEmpty(
settings,
'carouselCaptionFontFamily',
element.carouselCaptionFontFamily,
);
}
// Media type settings
if (isMediaElementType(elementType)) {
addIfNotEmpty(settings, 'mediaUrl', element.mediaUrl);
if (element.mediaAutoplay !== undefined) {
settings.mediaAutoplay = element.mediaAutoplay;
}
if (element.mediaLoop !== undefined) {
settings.mediaLoop = element.mediaLoop;
}
if (element.mediaMuted !== undefined) {
settings.mediaMuted = element.mediaMuted;
}
}
// Logo/spot type settings
if (isLogoElementType(elementType) || isSpotElementType(elementType)) {
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
}
return settings;
};
/**
* Type detection helpers for element types.
* Single source of truth used across the application.
*/
/**
* Check if a type is a navigation element type
*/
export const isNavigationElementType = (
type: string,
): type is 'navigation_next' | 'navigation_prev' =>
type === 'navigation_next' || type === 'navigation_prev';
/**
* Check if a type is a tooltip element type
*/
export const isTooltipElementType = (type: string): type is 'tooltip' =>
type === 'tooltip';
/**
* Check if a type is a description element type
*/
export const isDescriptionElementType = (type: string): type is 'description' =>
type === 'description';
/**
* Check if a type is a gallery element type
*/
export const isGalleryElementType = (type: string): type is 'gallery' =>
type === 'gallery';
/**
* Check if a type is a carousel element type
*/
export const isCarouselElementType = (type: string): type is 'carousel' =>
type === 'carousel';
/**
* Check if a type is a media (video/audio player) element type
*/
export const isMediaElementType = (
type: string,
): type is 'video_player' | 'audio_player' =>
type === 'video_player' || type === 'audio_player';
/**
* Check if a type is a video player element type
*/
export const isVideoPlayerElementType = (
type: string,
): type is 'video_player' => type === 'video_player';
/**
* Check if a type is an audio player element type
*/
export const isAudioPlayerElementType = (
type: string,
): type is 'audio_player' => type === 'audio_player';
/**
* Check if a type is a logo element type
*/
export const isLogoElementType = (type: string): type is 'logo' =>
type === 'logo';
/**
* Check if a type is a spot (hotspot) element type
*/
export const isSpotElementType = (type: string): type is 'spot' =>
type === 'spot';
/**
* Check if a type is a popup element type
*/
export const isPopupElementType = (type: string): type is 'popup' =>
type === 'popup';