286 lines
7.6 KiB
TypeScript
286 lines
7.6 KiB
TypeScript
/**
|
|
* Constructor Helpers
|
|
*
|
|
* Utility functions for the constructor page.
|
|
* Extracted from constructor.tsx for reusability.
|
|
*/
|
|
|
|
import type {
|
|
CanvasElement,
|
|
ConstructorAsset as ProjectAsset,
|
|
AssetOption,
|
|
} from '../types/constructor';
|
|
import {
|
|
isGalleryElementType,
|
|
isCarouselElementType,
|
|
isTooltipElementType,
|
|
isDescriptionElementType,
|
|
isNavigationElementType,
|
|
isMediaElementType,
|
|
getNavigationButtonLabel,
|
|
} from './elementDefaults';
|
|
|
|
/**
|
|
* Clamp a number between min and max
|
|
*/
|
|
export const clamp = (value: number, min: number, max: number): number =>
|
|
Math.min(Math.max(value, min), max);
|
|
|
|
/**
|
|
* Get trimmed CSS value as string.
|
|
* Handles null/undefined gracefully.
|
|
*/
|
|
export const getTrimmedCssValue = (value: unknown): string => {
|
|
if (value === null || value === undefined) return '';
|
|
return String(value).trim();
|
|
};
|
|
|
|
/**
|
|
* Format asset label for display in select dropdowns.
|
|
* Shows asset name with source path suffix.
|
|
*/
|
|
export const getAssetLabel = (asset: ProjectAsset): string => {
|
|
const baseName = asset.name?.trim() || 'Untitled asset';
|
|
const source = String(asset.storage_key || asset.cdn_url || '').trim();
|
|
return `${baseName}${source ? ` · ${source}` : ''}`;
|
|
};
|
|
|
|
/**
|
|
* Get asset source value (storage_key or cdn_url).
|
|
* Used as the value in asset select dropdowns.
|
|
*/
|
|
export const getAssetSourceValue = (asset: ProjectAsset): string =>
|
|
String(asset.storage_key || asset.cdn_url || '').trim();
|
|
|
|
/**
|
|
* Check if an asset is likely a background image based on name/type.
|
|
* Used to filter assets for background image selection.
|
|
*/
|
|
export const isBackgroundImageAsset = (asset: ProjectAsset): boolean => {
|
|
if (asset.type) return asset.type === 'background_image';
|
|
const normalizedName = String(asset.name || '').toLowerCase();
|
|
if (!normalizedName) return false;
|
|
const hasBackgroundKeyword = /\bbackground\b|\bbg\b|backdrop|wallpaper/.test(
|
|
normalizedName,
|
|
);
|
|
const hasExcludedKeyword = /\bicon\b|\blogo\b/.test(normalizedName);
|
|
return hasBackgroundKeyword && !hasExcludedKeyword;
|
|
};
|
|
|
|
/**
|
|
* Add current value as a fallback option if not already present.
|
|
* Ensures the currently selected value is always available in the dropdown.
|
|
*/
|
|
export const addFallbackAssetOption = (
|
|
options: AssetOption[],
|
|
value?: string,
|
|
fallbackLabel?: string,
|
|
): AssetOption[] => {
|
|
const normalizedValue = String(value || '').trim();
|
|
if (!normalizedValue) return options;
|
|
if (options.some((option) => option.value === normalizedValue))
|
|
return options;
|
|
return [
|
|
...options,
|
|
{
|
|
value: normalizedValue,
|
|
label: fallbackLabel || `Custom URL · ${normalizedValue}`,
|
|
},
|
|
];
|
|
};
|
|
|
|
/**
|
|
* Get element button title for display in constructor menu.
|
|
* Shows element-specific info like card/slide count.
|
|
*/
|
|
export const getElementButtonTitle = (element: CanvasElement): string => {
|
|
if (isGalleryElementType(element.type)) {
|
|
return `${element.label} (${element.galleryCards?.length || 0})`;
|
|
}
|
|
|
|
if (isCarouselElementType(element.type)) {
|
|
return `${element.label} (${element.carouselSlides?.length || 0})`;
|
|
}
|
|
|
|
if (isTooltipElementType(element.type)) return element.tooltipTitle ?? '';
|
|
if (isDescriptionElementType(element.type))
|
|
return element.descriptionTitle ?? '';
|
|
|
|
if (isNavigationElementType(element.type)) {
|
|
type NavigationElementType = 'navigation_next' | 'navigation_prev';
|
|
return (
|
|
element.navLabel?.trim() ||
|
|
getNavigationButtonLabel(element.type as NavigationElementType)
|
|
);
|
|
}
|
|
|
|
if (isMediaElementType(element.type) && element.mediaUrl) {
|
|
return `${element.label} · configured`;
|
|
}
|
|
|
|
return element.label;
|
|
};
|
|
|
|
/**
|
|
* Build asset options for a specific asset type.
|
|
* Filters assets by type and creates label/value pairs.
|
|
*/
|
|
export const buildAssetOptions = (
|
|
assets: ProjectAsset[],
|
|
assetType: 'image' | 'video' | 'audio',
|
|
additionalFilter?: (asset: ProjectAsset) => boolean,
|
|
): AssetOption[] => {
|
|
return assets
|
|
.filter((asset) => {
|
|
if (asset.asset_type !== assetType) return false;
|
|
if (!getAssetSourceValue(asset)) return false;
|
|
if (additionalFilter && !additionalFilter(asset)) return false;
|
|
return true;
|
|
})
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
}));
|
|
};
|
|
|
|
/**
|
|
* Build background image asset options.
|
|
* Filters for image assets that appear to be backgrounds.
|
|
*/
|
|
export const buildBackgroundImageOptions = (
|
|
assets: ProjectAsset[],
|
|
): AssetOption[] => {
|
|
return buildAssetOptions(assets, 'image', isBackgroundImageAsset);
|
|
};
|
|
|
|
/**
|
|
* Build video asset options.
|
|
*/
|
|
export const buildVideoAssetOptions = (
|
|
assets: ProjectAsset[],
|
|
): AssetOption[] => {
|
|
return buildAssetOptions(assets, 'video');
|
|
};
|
|
|
|
/**
|
|
* Build audio asset options.
|
|
*/
|
|
export const buildAudioAssetOptions = (
|
|
assets: ProjectAsset[],
|
|
): AssetOption[] => {
|
|
return buildAssetOptions(assets, 'audio');
|
|
};
|
|
|
|
/**
|
|
* Build transition video asset options.
|
|
* Prefers assets marked as type='transition', falls back to tagged or all videos.
|
|
*/
|
|
export const buildTransitionVideoOptions = (
|
|
assets: ProjectAsset[],
|
|
): AssetOption[] => {
|
|
// First try assets marked as transition type
|
|
const typedAssets = assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.type === 'transition' &&
|
|
asset.asset_type === 'video' &&
|
|
getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
}));
|
|
|
|
if (typedAssets.length > 0) return typedAssets;
|
|
|
|
// Fall back to assets with [TRANSITION] tag in name
|
|
const taggedAssets = assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.asset_type === 'video' &&
|
|
getAssetSourceValue(asset) &&
|
|
/\[TRANSITION\]/i.test(String(asset.name || '')),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
}));
|
|
|
|
if (taggedAssets.length > 0) return taggedAssets;
|
|
|
|
// Fall back to all video assets
|
|
return buildVideoAssetOptions(assets);
|
|
};
|
|
|
|
/**
|
|
* Build icon asset options.
|
|
* Filters for image assets marked as type='icon'.
|
|
*/
|
|
export const buildIconAssetOptions = (
|
|
assets: ProjectAsset[],
|
|
): AssetOption[] => {
|
|
return assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.type === 'icon' &&
|
|
asset.asset_type === 'image' &&
|
|
getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
}));
|
|
};
|
|
|
|
/**
|
|
* Build all image asset options (no filtering).
|
|
*/
|
|
export const buildImageAssetOptions = (
|
|
assets: ProjectAsset[],
|
|
): AssetOption[] => {
|
|
return buildAssetOptions(assets, 'image');
|
|
};
|
|
|
|
/**
|
|
* Extract numeric value from CSS value string.
|
|
* Used for compact style inputs that expect raw numbers.
|
|
*
|
|
* @example
|
|
* extractNumericValue('24vw'); // '24'
|
|
* extractNumericValue('100px'); // '100'
|
|
* extractNumericValue(''); // ''
|
|
*/
|
|
export const extractNumericValue = (value?: string): string => {
|
|
if (!value) return '';
|
|
const match = String(value).match(/^(-?\d+\.?\d*)/);
|
|
return match ? match[1] : '';
|
|
};
|
|
|
|
/**
|
|
* Build page name lookup map by ID.
|
|
*/
|
|
export const buildPageNameById = (
|
|
pages: Array<{ id: string; name?: string }>,
|
|
): Record<string, string> => {
|
|
const acc: Record<string, string> = {};
|
|
pages.forEach((page, index) => {
|
|
acc[String(page.id)] = page.name || `Page ${index + 1}`;
|
|
});
|
|
return acc;
|
|
};
|
|
|
|
/**
|
|
* Build page name lookup map by slug.
|
|
*/
|
|
export const buildPageNameBySlug = (
|
|
pages: Array<{ slug?: string; name?: string }>,
|
|
): Record<string, string> => {
|
|
const acc: Record<string, string> = {};
|
|
pages.forEach((page, index) => {
|
|
if (page.slug) {
|
|
acc[String(page.slug)] = page.name || `Page ${index + 1}`;
|
|
}
|
|
});
|
|
return acc;
|
|
};
|