/** * 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 = { 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> > = { 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, ): 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, ): 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, ): 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, 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; const defaultsRecord = defaults as unknown as Record; const mergedRecord = merged as unknown as Record; 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), ); // 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), ); } // 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), ); } return merged; }; /** * Parse element settings from JSON string or object. * Used when loading element defaults from the database. */ export const parseElementSettings = ( settingsValue?: string | Record, ): Partial => { if (!settingsValue) return {}; let settings: Record = {}; 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), ); } // Parse gallery info spans if present if (Array.isArray(settings.galleryInfoSpans)) { settings.galleryInfoSpans = settings.galleryInfoSpans.map((span) => normalizeGalleryInfoSpan(span as Record), ); } // Parse carousel slides if present if (Array.isArray(settings.carouselSlides)) { settings.carouselSlides = settings.carouselSlides.map((slide) => normalizeCarouselSlide(slide as Record), ); } return settings as Partial; }; /** * 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, 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, elementType: CanvasElementType | string, ): Record => { const settings: Record = {}; // 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)[prop]; addIfNotEmpty(settings, prop, value); }); // Effect properties (using shared constant) ELEMENT_EFFECT_PROPS.forEach((prop) => { const value = (element as Record)[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)[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';