diff --git a/shesha-reactjs/src/componentsApi/componentApi.ts b/shesha-reactjs/src/componentsApi/componentApi.ts index 659605f31b..7455bd564e 100644 --- a/shesha-reactjs/src/componentsApi/componentApi.ts +++ b/shesha-reactjs/src/componentsApi/componentApi.ts @@ -374,6 +374,13 @@ export interface PanelApi extends CommonComponentApi { collapse(): void; }; +export interface YouTubeVideoApi extends CommonComponentApi { + /** Whether the viewer has started watching the video (playback began at least once) */ + readonly isWatched: boolean; + /** Whether the viewer has watched the video all the way to the end */ + readonly isWatchedEntirely: boolean; +}; + export interface ButtonApi extends CommonComponentApi { /** Focus on component */ focus(): void; diff --git a/shesha-reactjs/src/designer-components/youtubeVideo/index.tsx b/shesha-reactjs/src/designer-components/youtubeVideo/index.tsx index fadab27b1f..9434e4eb5d 100644 --- a/shesha-reactjs/src/designer-components/youtubeVideo/index.tsx +++ b/shesha-reactjs/src/designer-components/youtubeVideo/index.tsx @@ -15,7 +15,10 @@ import { migratePropertyName, migrateCustomFunctions } from '@/designer-componen import { migrateVisibility } from '@/designer-components/_common-migrations/migrateVisibility'; import { migrateFormApi } from '../_common-migrations/migrateFormApi1'; import { removeUndefinedProps } from '@/utils/object'; -import { ConfigurableFormItem } from '@/components/formDesigner/components/formItem'; +import { useComponentApi } from '@/providers/componentApi/provider'; +import { useEffectOnce } from '@/hooks/useEffectOnce'; +import { YouTubeVideoApi } from '@/componentsApi/componentApi'; +import apiCode from '../../componentsApi/componentApi.ts?raw'; import { loadYouTubeIframeApi, YTPlayer, YTPlayerEvent } from './youtubeApi'; import { useStyles } from './styles'; @@ -23,8 +26,7 @@ const { Title, Paragraph } = Typography; const YoutubeVideoComponent: IToolboxComponent = { type: 'youtubeVideo', - isInput: true, - isOutput: true, + isInput: false, name: 'YouTube Video', icon: , Factory: ({ model, calculatedModel }) => { @@ -53,7 +55,6 @@ const YoutubeVideoComponent: IToolboxComponent(null); - const onChangeRef = useRef<((value: boolean | undefined | null) => void) | null>(null); + + // Actual playback state, kept in refs so the exposed API getters always read the latest value + // (the getters are called later, from the form's events/expressions). + const hasStartedRef = useRef(false); + const isCompletedRef = useRef(false); const { executeAction } = useConfigurableActionDispatcher(); const allConstants = useAvailableConstantsData(); @@ -73,11 +78,26 @@ const YoutubeVideoComponent: IToolboxComponent { + componentApi?.updateApi({ + id: model.id, + componentName: model.componentName ?? '', + level: 3, + typeDefinition: { typeName: 'YouTubeVideoApi', files: [{ content: apiCode, fileName: 'apis/componentApi.ts' }] }, + properties: [ + { name: 'isWatched', getter: () => hasStartedRef.current }, + { name: 'isWatchedEntirely', getter: () => isCompletedRef.current }, + ], + }); + }, [componentApi, model.componentName, model.id]); + useEffectOnce(() => () => componentApi?.removeApi(model.id)); + + // titleLevel arrives from the dropdown as a number, but older configs / JS-setting bindings may + // deliver a string ('1'..'5'); Ant's Title needs a 1-5 number. Fall back to 3 (default). const parsedTitleLevel = Number(titleLevel); const resolvedTitleLevel = (Number.isFinite(parsedTitleLevel) && parsedTitleLevel >= 1 && parsedTitleLevel <= 5 ? parsedTitleLevel @@ -106,57 +126,49 @@ const YoutubeVideoComponent: IToolboxComponent { if (value === undefined) { return undefined; } - const strValue = String(value).trim(); - - // If already a percentage, return as-is if (strValue.endsWith('%')) { return strValue; } - - // Extract numeric part from "500px", "500", etc. - const numericPart = strValue.match(/^(\d+(?:\.\d+)?)/)?.[1]; + const numericPart = /^(\d+(?:\.\d+)?)/.exec(strValue)?.[1]; if (numericPart == null) { return undefined; } - - const numeric = parseFloat(numericPart); + const numeric = Number.parseFloat(numericPart); if (!Number.isFinite(numeric) || numeric < 0) { return undefined; } - return `${numeric}%`; }; - // Intercept dimensions when responsive mode is enabled - let finalDimensionStyles = { ...model.allStyles?.dimensionsStyles }; - - if (responsive && model.dimensions != null) { - // Convert configured width to a percentage for the 16:9 responsive container - finalDimensionStyles = { - ...finalDimensionStyles, - width: toPercentage(model.dimensions.width) ?? '100%', - height: undefined, // Remove height for aspect ratio - }; - } else if (!responsive) { - if (finalDimensionStyles.width === undefined && finalDimensionStyles.height === undefined) { - finalDimensionStyles = { - ...finalDimensionStyles, - width: 560, - height: 315, - }; + // Resolved, device-aware dimensions come from `model.allStyles.dimensionsStyles` — NOT + // `model.dimensions`, which is always empty because the Appearance inputs are stored per-device + // (desktop/tablet/mobile). Responsive: a 16:9 container drives height, so height/min/max-height + // are dropped and the width becomes a percentage. Fixed: configured width/height (default 560x315). + const dimensionStyles = model.allStyles?.dimensionsStyles ?? {}; + const finalDimensionStyles = responsive + ? { + ...dimensionStyles, + width: toPercentage(dimensionStyles.width) ?? '100%', + height: undefined, + minHeight: undefined, + maxHeight: undefined, + } + : { + ...dimensionStyles, + width: dimensionStyles.width ?? 560, + height: dimensionStyles.height ?? 315, }; - } - // Apply all styles to component wrapper + // Apply all styles to the component wrapper const componentStyles = removeUndefinedProps({ ...model.allStyles?.jsStyle, - ...finalDimensionStyles, // Use intercepted dimensions + ...finalDimensionStyles, ...model.allStyles?.borderStyles, ...model.allStyles?.shadowStyles, ...model.allStyles?.stylingBoxAsCSS, @@ -196,13 +208,11 @@ const YoutubeVideoComponent: IToolboxComponent