Autosave: 20260319-050018
This commit is contained in:
parent
d4821b6a5d
commit
08ac54f0b5
@ -76,12 +76,16 @@ type NavigationElementType = Extract<
|
|||||||
'navigation_next' | 'navigation_prev'
|
'navigation_next' | 'navigation_prev'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type NavigationButtonKind = 'forward' | 'back';
|
||||||
|
|
||||||
type CanvasElement = {
|
type CanvasElement = {
|
||||||
id: string;
|
id: string;
|
||||||
type: CanvasElementType;
|
type: CanvasElementType;
|
||||||
label: string;
|
label: string;
|
||||||
xPercent: number;
|
xPercent: number;
|
||||||
yPercent: number;
|
yPercent: number;
|
||||||
|
appearDelaySec?: number;
|
||||||
|
appearDurationSec?: number | null;
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
galleryCards?: GalleryCard[];
|
galleryCards?: GalleryCard[];
|
||||||
carouselSlides?: CarouselSlide[];
|
carouselSlides?: CarouselSlide[];
|
||||||
@ -92,6 +96,7 @@ type CanvasElement = {
|
|||||||
descriptionTitle?: string;
|
descriptionTitle?: string;
|
||||||
descriptionText?: string;
|
descriptionText?: string;
|
||||||
navLabel?: string;
|
navLabel?: string;
|
||||||
|
navType?: NavigationButtonKind;
|
||||||
targetPageId?: string;
|
targetPageId?: string;
|
||||||
transitionVideoUrl?: string;
|
transitionVideoUrl?: string;
|
||||||
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
||||||
@ -164,6 +169,19 @@ const parseJsonObject = <T,>(value?: unknown, fallback?: T): T => {
|
|||||||
const clamp = (value: number, min: number, max: number) =>
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
Math.min(Math.max(value, min), max);
|
Math.min(Math.max(value, min), max);
|
||||||
|
|
||||||
|
const normalizeAppearDelaySec = (value: unknown) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||||||
|
return Number(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAppearDurationSec = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||||
|
return Number(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
const createLocalId = () => {
|
const createLocalId = () => {
|
||||||
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
||||||
return window.crypto.randomUUID();
|
return window.crypto.randomUUID();
|
||||||
@ -228,6 +246,18 @@ const extractS3ObjectKey = (value: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDurationNote = (durationSec?: number | string | null) => {
|
||||||
|
const parsed = Number(durationSec);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown';
|
||||||
|
|
||||||
|
const totalSeconds = Math.round(parsed);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (minutes <= 0) return `Duration: ${seconds}s`;
|
||||||
|
return `Duration: ${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
const resolveAssetPlaybackUrl = (value?: string) => {
|
const resolveAssetPlaybackUrl = (value?: string) => {
|
||||||
const normalized = String(value || '').trim();
|
const normalized = String(value || '').trim();
|
||||||
if (!normalized) return '';
|
if (!normalized) return '';
|
||||||
@ -258,6 +288,87 @@ const resolveAssetPlaybackUrl = (value?: string) => {
|
|||||||
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`;
|
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readMediaDuration = (
|
||||||
|
playbackUrl: string,
|
||||||
|
mediaType: 'video' | 'audio',
|
||||||
|
): Promise<number | null> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const mediaElement =
|
||||||
|
mediaType === 'video'
|
||||||
|
? document.createElement('video')
|
||||||
|
: document.createElement('audio');
|
||||||
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
mediaElement.removeEventListener('error', onError);
|
||||||
|
mediaElement.removeEventListener('abort', onError);
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
mediaElement.pause();
|
||||||
|
mediaElement.removeAttribute('src');
|
||||||
|
mediaElement.load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => {
|
||||||
|
const duration = Number(mediaElement.duration);
|
||||||
|
cleanup();
|
||||||
|
if (Number.isFinite(duration) && duration > 0) {
|
||||||
|
resolve(duration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
}, 12000);
|
||||||
|
|
||||||
|
mediaElement.preload = 'metadata';
|
||||||
|
mediaElement.crossOrigin = 'anonymous';
|
||||||
|
mediaElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
mediaElement.addEventListener('error', onError);
|
||||||
|
mediaElement.addEventListener('abort', onError);
|
||||||
|
mediaElement.src = playbackUrl;
|
||||||
|
mediaElement.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveDurationWithFallback = async (
|
||||||
|
source: string,
|
||||||
|
mediaType: 'video' | 'audio',
|
||||||
|
) => {
|
||||||
|
const playbackUrl = resolveAssetPlaybackUrl(source);
|
||||||
|
if (!playbackUrl) return null;
|
||||||
|
|
||||||
|
const directDuration = await readMediaDuration(playbackUrl, mediaType);
|
||||||
|
if (Number.isFinite(directDuration) && Number(directDuration) > 0) {
|
||||||
|
return Number(directDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(playbackUrl, { responseType: 'blob' });
|
||||||
|
const blobUrl = URL.createObjectURL(response.data);
|
||||||
|
try {
|
||||||
|
const blobDuration = await readMediaDuration(blobUrl, mediaType);
|
||||||
|
if (Number.isFinite(blobDuration) && Number(blobDuration) > 0) {
|
||||||
|
return Number(blobDuration);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch media for duration probing:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isBackgroundImageAsset = (asset: ProjectAsset) => {
|
const isBackgroundImageAsset = (asset: ProjectAsset) => {
|
||||||
if (asset.type) return asset.type === 'background_image';
|
if (asset.type) return asset.type === 'background_image';
|
||||||
const normalizedName = String(asset.name || '').toLowerCase();
|
const normalizedName = String(asset.name || '').toLowerCase();
|
||||||
@ -306,6 +417,14 @@ const isNavigationElementType = (
|
|||||||
const getNavigationButtonLabel = (type: NavigationElementType) =>
|
const getNavigationButtonLabel = (type: NavigationElementType) =>
|
||||||
type === 'navigation_next' ? 'Forward' : 'Back';
|
type === 'navigation_next' ? 'Forward' : 'Back';
|
||||||
|
|
||||||
|
const getNavigationButtonKind = (type: NavigationElementType): NavigationButtonKind =>
|
||||||
|
type === 'navigation_prev' ? 'back' : 'forward';
|
||||||
|
|
||||||
|
const getNavigationTypeFromKind = (
|
||||||
|
kind: NavigationButtonKind,
|
||||||
|
): NavigationElementType =>
|
||||||
|
kind === 'back' ? 'navigation_prev' : 'navigation_next';
|
||||||
|
|
||||||
const createDefaultElement = (
|
const createDefaultElement = (
|
||||||
type: CanvasElementType,
|
type: CanvasElementType,
|
||||||
index: number,
|
index: number,
|
||||||
@ -316,6 +435,8 @@ const createDefaultElement = (
|
|||||||
label: labelByType[type],
|
label: labelByType[type],
|
||||||
xPercent: clamp(12 + index * 4, 5, 80),
|
xPercent: clamp(12 + index * 4, 5, 80),
|
||||||
yPercent: clamp(16 + index * 6, 8, 85),
|
yPercent: clamp(16 + index * 6, 8, 85),
|
||||||
|
appearDelaySec: 0,
|
||||||
|
appearDurationSec: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'gallery') {
|
if (type === 'gallery') {
|
||||||
@ -360,6 +481,7 @@ const createDefaultElement = (
|
|||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
navLabel: getNavigationButtonLabel(type),
|
navLabel: getNavigationButtonLabel(type),
|
||||||
|
navType: getNavigationButtonKind(type),
|
||||||
iconUrl: '',
|
iconUrl: '',
|
||||||
transitionReverseMode: 'auto_reverse',
|
transitionReverseMode: 'auto_reverse',
|
||||||
transitionDurationSec: 0.7,
|
transitionDurationSec: 0.7,
|
||||||
@ -452,6 +574,9 @@ const ConstructorPage = () => {
|
|||||||
const [newTransitionDurationSec, setNewTransitionDurationSec] = useState(0.7);
|
const [newTransitionDurationSec, setNewTransitionDurationSec] = useState(0.7);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
|
const [resolvedDurationBySource, setResolvedDurationBySource] = useState<
|
||||||
|
Record<string, number | null>
|
||||||
|
>({});
|
||||||
|
|
||||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 110 });
|
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 110 });
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
@ -470,6 +595,7 @@ const ConstructorPage = () => {
|
|||||||
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
|
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const reverseAnimationFrame = useRef<number | null>(null);
|
const reverseAnimationFrame = useRef<number | null>(null);
|
||||||
const didSetInitialCanvasFocus = useRef(false);
|
const didSetInitialCanvasFocus = useRef(false);
|
||||||
|
const durationProbeInFlightRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const activePage = useMemo(
|
const activePage = useMemo(
|
||||||
() => pages.find((item) => item.id === activePageId) || null,
|
() => pages.find((item) => item.id === activePageId) || null,
|
||||||
@ -481,6 +607,7 @@ const ConstructorPage = () => {
|
|||||||
);
|
);
|
||||||
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
||||||
if (pages.length <= 1) return ['navigation_next'];
|
if (pages.length <= 1) return ['navigation_next'];
|
||||||
|
if (activePageIndex < 0) return ['navigation_next', 'navigation_prev'];
|
||||||
if (activePageIndex <= 0) return ['navigation_next'];
|
if (activePageIndex <= 0) return ['navigation_next'];
|
||||||
if (activePageIndex >= pages.length - 1) return ['navigation_prev'];
|
if (activePageIndex >= pages.length - 1) return ['navigation_prev'];
|
||||||
return ['navigation_next', 'navigation_prev'];
|
return ['navigation_next', 'navigation_prev'];
|
||||||
@ -512,6 +639,7 @@ const ConstructorPage = () => {
|
|||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
type: nextType,
|
type: nextType,
|
||||||
|
navType: getNavigationButtonKind(nextType),
|
||||||
label: hasDefaultLabel ? labelByType[nextType] : element.label,
|
label: hasDefaultLabel ? labelByType[nextType] : element.label,
|
||||||
navLabel: hasDefaultNavLabel ? nextButtonLabel : element.navLabel,
|
navLabel: hasDefaultNavLabel ? nextButtonLabel : element.navLabel,
|
||||||
};
|
};
|
||||||
@ -615,6 +743,112 @@ const ConstructorPage = () => {
|
|||||||
})),
|
})),
|
||||||
[assets],
|
[assets],
|
||||||
);
|
);
|
||||||
|
const getKnownDurationForSource = useCallback(
|
||||||
|
(source?: string) => {
|
||||||
|
const normalizedSource = String(source || '').trim();
|
||||||
|
if (!normalizedSource) return null;
|
||||||
|
|
||||||
|
const resolvedDuration = resolvedDurationBySource[normalizedSource];
|
||||||
|
if (Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0) {
|
||||||
|
return Number(resolvedDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[resolvedDurationBySource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const durationProbeTargets = useMemo<
|
||||||
|
Array<{ source: string; mediaType: 'video' | 'audio' }>
|
||||||
|
>(() => {
|
||||||
|
const targets: Array<{ source: string; mediaType: 'video' | 'audio' }> = [];
|
||||||
|
|
||||||
|
if (backgroundVideoUrl) {
|
||||||
|
targets.push({ source: backgroundVideoUrl, mediaType: 'video' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backgroundAudioUrl) {
|
||||||
|
targets.push({ source: backgroundAudioUrl, mediaType: 'audio' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedElement &&
|
||||||
|
(selectedElement.type === 'video_player' ||
|
||||||
|
selectedElement.type === 'audio_player') &&
|
||||||
|
selectedElement.mediaUrl
|
||||||
|
) {
|
||||||
|
targets.push({
|
||||||
|
source: selectedElement.mediaUrl,
|
||||||
|
mediaType: selectedElement.type === 'video_player' ? 'video' : 'audio',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets;
|
||||||
|
}, [backgroundAudioUrl, backgroundVideoUrl, selectedElement]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
durationProbeTargets.forEach(({ source, mediaType }) => {
|
||||||
|
const normalizedSource = String(source || '').trim();
|
||||||
|
if (!normalizedSource) return;
|
||||||
|
|
||||||
|
if (getKnownDurationForSource(normalizedSource)) return;
|
||||||
|
|
||||||
|
const probeKey = `${mediaType}:${normalizedSource}`;
|
||||||
|
if (durationProbeInFlightRef.current.has(probeKey)) return;
|
||||||
|
durationProbeInFlightRef.current.add(probeKey);
|
||||||
|
|
||||||
|
resolveDurationWithFallback(normalizedSource, mediaType)
|
||||||
|
.then((duration) => {
|
||||||
|
if (isCancelled) return;
|
||||||
|
setResolvedDurationBySource((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalizedSource]:
|
||||||
|
Number.isFinite(duration) && Number(duration) > 0
|
||||||
|
? Number(duration)
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to resolve media duration:', error);
|
||||||
|
if (isCancelled) return;
|
||||||
|
setResolvedDurationBySource((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalizedSource]: null,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
durationProbeInFlightRef.current.delete(probeKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [durationProbeTargets, getKnownDurationForSource]);
|
||||||
|
|
||||||
|
const backgroundVideoDurationNote = useMemo(
|
||||||
|
() => formatDurationNote(getKnownDurationForSource(backgroundVideoUrl)),
|
||||||
|
[backgroundVideoUrl, getKnownDurationForSource],
|
||||||
|
);
|
||||||
|
const backgroundAudioDurationNote = useMemo(
|
||||||
|
() => formatDurationNote(getKnownDurationForSource(backgroundAudioUrl)),
|
||||||
|
[backgroundAudioUrl, getKnownDurationForSource],
|
||||||
|
);
|
||||||
|
const selectedMediaDurationNote = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!selectedElement ||
|
||||||
|
(selectedElement.type !== 'video_player' &&
|
||||||
|
selectedElement.type !== 'audio_player')
|
||||||
|
) {
|
||||||
|
return 'Duration: unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDurationNote(
|
||||||
|
getKnownDurationForSource(selectedElement.mediaUrl || ''),
|
||||||
|
);
|
||||||
|
}, [getKnownDurationForSource, selectedElement]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (newTransitionVideoUrl) return;
|
if (newTransitionVideoUrl) return;
|
||||||
@ -762,6 +996,8 @@ const ConstructorPage = () => {
|
|||||||
label: labelByType[item.type as CanvasElementType],
|
label: labelByType[item.type as CanvasElementType],
|
||||||
xPercent: clamp(Number(item.xPercent || 0), 0, 100),
|
xPercent: clamp(Number(item.xPercent || 0), 0, 100),
|
||||||
yPercent: clamp(Number(item.yPercent || 0), 0, 100),
|
yPercent: clamp(Number(item.yPercent || 0), 0, 100),
|
||||||
|
appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec),
|
||||||
|
appearDurationSec: normalizeAppearDurationSec(item.appearDurationSec),
|
||||||
galleryCards: Array.isArray(item.galleryCards)
|
galleryCards: Array.isArray(item.galleryCards)
|
||||||
? item.galleryCards.map((card: any, index: number) => ({
|
? item.galleryCards.map((card: any, index: number) => ({
|
||||||
id: String(card?.id || createLocalId()),
|
id: String(card?.id || createLocalId()),
|
||||||
@ -799,6 +1035,12 @@ const ConstructorPage = () => {
|
|||||||
? item.descriptionText
|
? item.descriptionText
|
||||||
: '',
|
: '',
|
||||||
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
|
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
|
||||||
|
navType:
|
||||||
|
item.navType === 'back' || item.navType === 'forward'
|
||||||
|
? item.navType
|
||||||
|
: isNavigationElementType(item.type as CanvasElementType)
|
||||||
|
? getNavigationButtonKind(item.type as NavigationElementType)
|
||||||
|
: undefined,
|
||||||
targetPageId:
|
targetPageId:
|
||||||
typeof item.targetPageId === 'string' ? item.targetPageId : '',
|
typeof item.targetPageId === 'string' ? item.targetPageId : '',
|
||||||
transitionVideoUrl:
|
transitionVideoUrl:
|
||||||
@ -1861,6 +2103,9 @@ const ConstructorPage = () => {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<p className='mt-1 text-[11px] text-gray-500'>
|
||||||
|
{backgroundVideoDurationNote}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1883,6 +2128,9 @@ const ConstructorPage = () => {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<p className='mt-1 text-[11px] text-gray-500'>
|
||||||
|
{backgroundAudioDurationNote}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1952,17 +2200,61 @@ const ConstructorPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedElement && (
|
{selectedElement && (
|
||||||
<div className='mb-2'>
|
<div className='mb-2 space-y-2'>
|
||||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
<div>
|
||||||
Label
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
</label>
|
Label
|
||||||
<input
|
</label>
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
<input
|
||||||
value={selectedElement.label}
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
onChange={(event) =>
|
value={selectedElement.label}
|
||||||
updateSelectedElement({ label: event.target.value })
|
onChange={(event) =>
|
||||||
}
|
updateSelectedElement({ label: event.target.value })
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
|
Appear delay (sec)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
type='number'
|
||||||
|
min='0'
|
||||||
|
step='0.1'
|
||||||
|
value={selectedElement.appearDelaySec ?? 0}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSelectedElement({
|
||||||
|
appearDelaySec: normalizeAppearDelaySec(
|
||||||
|
event.target.value,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
|
Appear duration (sec)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
type='number'
|
||||||
|
min='0.1'
|
||||||
|
step='0.1'
|
||||||
|
placeholder='Unlimited'
|
||||||
|
value={selectedElement.appearDurationSec ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSelectedElement({
|
||||||
|
appearDurationSec: normalizeAppearDurationSec(
|
||||||
|
event.target.value,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-[11px] text-gray-500'>
|
||||||
|
Leave empty for unlimited.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1972,17 +2264,22 @@ const ConstructorPage = () => {
|
|||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
Direction
|
Type
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={selectedElement.type}
|
value={
|
||||||
|
selectedElement.navType === 'back' ||
|
||||||
|
selectedElement.navType === 'forward'
|
||||||
|
? selectedElement.navType
|
||||||
|
: getNavigationButtonKind(selectedElement.type)
|
||||||
|
}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const rawValue = event.target.value;
|
const requestedKind: NavigationButtonKind =
|
||||||
const requestedType: NavigationElementType =
|
event.target.value === 'back' ? 'back' : 'forward';
|
||||||
rawValue === 'navigation_prev'
|
const requestedType = getNavigationTypeFromKind(
|
||||||
? 'navigation_prev'
|
requestedKind,
|
||||||
: 'navigation_next';
|
);
|
||||||
const nextType = allowedNavigationTypes.includes(
|
const nextType = allowedNavigationTypes.includes(
|
||||||
requestedType,
|
requestedType,
|
||||||
)
|
)
|
||||||
@ -1995,15 +2292,23 @@ const ConstructorPage = () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
disabled={allowedNavigationTypes.length === 1}
|
|
||||||
>
|
>
|
||||||
{allowedNavigationTypes.map((type) => (
|
<option
|
||||||
<option key={type} value={type}>
|
value='forward'
|
||||||
{type === 'navigation_next'
|
disabled={
|
||||||
? 'Forward'
|
!allowedNavigationTypes.includes('navigation_next')
|
||||||
: 'Back'}
|
}
|
||||||
</option>
|
>
|
||||||
))}
|
Forward
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value='back'
|
||||||
|
disabled={
|
||||||
|
!allowedNavigationTypes.includes('navigation_prev')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -2092,6 +2397,9 @@ const ConstructorPage = () => {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<p className='mt-1 text-[11px] text-gray-500'>
|
||||||
|
{selectedMediaDurationNote}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user