Added ability to set external URL for navigation buttons

This commit is contained in:
Dmitri 2026-06-27 13:50:58 +02:00
parent 490dd98e52
commit e14db16290
13 changed files with 358 additions and 236 deletions

View File

@ -483,6 +483,10 @@ class TourPagesService extends BaseService {
* @private * @private
*/ */
static isForwardElementWithTarget(element) { static isForwardElementWithTarget(element) {
if (element.navigationTargetMode === 'external_url') {
return false;
}
const isForward = const isForward =
element.type === 'navigation_next' || element.type === 'navigation_next' ||
(element.type?.startsWith?.('navigation') && (element.type?.startsWith?.('navigation') &&

View File

@ -175,7 +175,6 @@ export function ElementEditorPanel({
activePageId, activePageId,
allowedNavigationTypes, allowedNavigationTypes,
normalizeNavigationType, normalizeNavigationType,
onPreviewTransition,
} = useConstructorNavigation(); } = useConstructorNavigation();
const { activeTab, setActiveTab } = useConstructorEditorTab(); const { activeTab, setActiveTab } = useConstructorEditorTab();
@ -336,7 +335,11 @@ export function ElementEditorPanel({
} }
navDisabled={selectedElement.navDisabled || false} navDisabled={selectedElement.navDisabled || false}
iconUrl={selectedElement.iconUrl || ''} iconUrl={selectedElement.iconUrl || ''}
navigationTargetMode={
selectedElement.navigationTargetMode || 'target_page'
}
targetPageSlug={selectedElement.targetPageSlug || ''} targetPageSlug={selectedElement.targetPageSlug || ''}
externalUrl={selectedElement.externalUrl || ''}
transitionVideoUrl={ transitionVideoUrl={
selectedElement.transitionVideoUrl || '' selectedElement.transitionVideoUrl || ''
} }
@ -363,10 +366,25 @@ export function ElementEditorPanel({
} }
onChange={(prop, value) => { onChange={(prop, value) => {
if (prop === 'type') { if (prop === 'type') {
const nextType = value as NavigationElementType; if (typeof value === 'object') {
updateSelectedElement( const nextType = (value.type ||
normalizeNavigationType(selectedElement, nextType), selectedElement.type) as NavigationElementType;
); updateSelectedElement({
...normalizeNavigationType(
selectedElement,
nextType,
),
...value,
});
} else {
const nextType = value as NavigationElementType;
updateSelectedElement(
normalizeNavigationType(
selectedElement,
nextType,
),
);
}
} else if (prop === 'transitionVideoUrl') { } else if (prop === 'transitionVideoUrl') {
const nextVideoUrl = value as string; const nextVideoUrl = value as string;
const resolvedDuration = getDuration(nextVideoUrl); const resolvedDuration = getDuration(nextVideoUrl);
@ -380,13 +398,16 @@ export function ElementEditorPanel({
targetPageSlug: value as string, targetPageSlug: value as string,
targetPageId: '', targetPageId: '',
}); });
} else if (prop === 'navigationTargetMode') {
if (typeof value === 'object') {
updateSelectedElement(value);
}
} else { } else {
updateSelectedElement({ updateSelectedElement({
[prop]: value, [prop]: value,
}); });
} }
}} }}
onPreviewTransition={onPreviewTransition}
/> />
)} )}

View File

@ -203,7 +203,6 @@ export interface ElementEditorPanelProps {
onUpdateTransitionVideoUrl: (url: string) => void; onUpdateTransitionVideoUrl: (url: string) => void;
onUpdateTransitionSupportsReverse: (value: boolean) => void; onUpdateTransitionSupportsReverse: (value: boolean) => void;
onCreateTransition: () => void; onCreateTransition: () => void;
onPreviewTransition: (direction: 'forward' | 'back') => void;
// Gallery operations // Gallery operations
onAddGalleryCard: () => void; onAddGalleryCard: () => void;

View File

@ -6,10 +6,10 @@
*/ */
import React from 'react'; import React from 'react';
import BaseButton from '../BaseButton';
import type { import type {
AssetOption, AssetOption,
NavigationButtonKind, NavigationButtonKind,
NavigationTargetMode,
CanvasElementType, CanvasElementType,
} from '../../types/constructor'; } from '../../types/constructor';
import type { TransitionType, EasingFunction } from '../../types/transition'; import type { TransitionType, EasingFunction } from '../../types/transition';
@ -42,7 +42,9 @@ interface NavigationSettingsSectionCompactProps {
navLabelFontFamily: string; navLabelFontFamily: string;
navDisabled: boolean; navDisabled: boolean;
iconUrl: string; iconUrl: string;
navigationTargetMode: NavigationTargetMode;
targetPageSlug: string; targetPageSlug: string;
externalUrl: string;
transitionVideoUrl: string; transitionVideoUrl: string;
transitionReverseMode: 'auto_reverse' | 'separate_video'; transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string; reverseVideoUrl: string;
@ -67,11 +69,14 @@ interface NavigationSettingsSectionCompactProps {
| Partial<{ | Partial<{
type: NavigationElementType; type: NavigationElementType;
navType: NavigationButtonKind; navType: NavigationButtonKind;
navigationTargetMode: NavigationTargetMode;
label?: string; label?: string;
navLabel?: string; navLabel?: string;
targetPageSlug?: string;
targetPageId?: string;
externalUrl?: string;
}>, }>,
) => void; ) => void;
onPreviewTransition?: (direction: 'forward' | 'back') => void;
} }
const NavigationSettingsSectionCompact: React.FC< const NavigationSettingsSectionCompact: React.FC<
@ -83,7 +88,9 @@ const NavigationSettingsSectionCompact: React.FC<
navLabelFontFamily, navLabelFontFamily,
navDisabled, navDisabled,
iconUrl, iconUrl,
navigationTargetMode,
targetPageSlug, targetPageSlug,
externalUrl,
transitionVideoUrl, transitionVideoUrl,
transitionReverseMode, transitionReverseMode,
reverseVideoUrl, reverseVideoUrl,
@ -99,10 +106,13 @@ const NavigationSettingsSectionCompact: React.FC<
selectedMediaDurationNote, selectedMediaDurationNote,
selectedTransitionDurationNote, selectedTransitionDurationNote,
onChange, onChange,
onPreviewTransition,
}) => { }) => {
const currentTargetMode: NavigationTargetMode =
navigationTargetMode === 'external_url' ? 'external_url' : 'target_page';
const currentKind: NavigationButtonKind = const currentKind: NavigationButtonKind =
navType || (type === 'navigation_prev' ? 'back' : 'forward'); currentTargetMode === 'external_url'
? 'forward'
: navType || (type === 'navigation_prev' ? 'back' : 'forward');
return ( return (
<div className='space-y-2'> <div className='space-y-2'>
@ -121,7 +131,15 @@ const NavigationSettingsSectionCompact: React.FC<
const nextType = allowedNavigationTypes.includes(requestedType) const nextType = allowedNavigationTypes.includes(requestedType)
? requestedType ? requestedType
: allowedNavigationTypes[0]; : allowedNavigationTypes[0];
onChange('type', nextType); onChange('type', {
type: nextType,
navType: requestedKind,
navigationTargetMode:
requestedKind === 'back' ? 'target_page' : currentTargetMode,
targetPageSlug: requestedKind === 'back' ? '' : targetPageSlug,
targetPageId: '',
externalUrl: requestedKind === 'back' ? '' : externalUrl,
});
}} }}
> >
<option <option
@ -221,217 +239,252 @@ const NavigationSettingsSectionCompact: React.FC<
<> <>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Target page Destination
</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={targetPageSlug} value={currentTargetMode}
onChange={(event) => { onChange={(event) => {
onChange('targetPageSlug', event.target.value); const nextMode: NavigationTargetMode =
onChange('targetPageId', ''); // Clear legacy ID event.target.value === 'external_url'
? 'external_url'
: 'target_page';
onChange('navigationTargetMode', {
type: 'navigation_next',
navType: 'forward',
navigationTargetMode: nextMode,
targetPageSlug:
nextMode === 'target_page' ? targetPageSlug : '',
targetPageId: '',
externalUrl: nextMode === 'external_url' ? externalUrl : '',
});
}} }}
> >
<option value=''>Not selected</option> <option value='target_page'>Target page</option>
{pages <option value='external_url'>External URL</option>
.filter((page) => page.id !== activePageId)
.map((page, index) => (
<option key={page.id} value={page.slug || ''}>
{page.name || `Page ${index + 1}`}
</option>
))}
</select> </select>
</div> </div>
<div> {currentTargetMode === 'target_page' && (
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Transition video asset
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionVideoUrl}
onChange={(event) => {
onChange('transitionVideoUrl', event.target.value);
}}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
transitionVideoOptions,
transitionVideoUrl,
`Current video · ${transitionVideoUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{selectedTransitionDurationNote && (
<p className='mt-1 text-[11px] text-white/60'>
{selectedTransitionDurationNote}
</p>
)}
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Back transition mode
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionReverseMode}
onChange={(event) =>
onChange(
'transitionReverseMode',
event.target.value === 'separate_video'
? 'separate_video'
: 'auto_reverse',
)
}
>
<option value='auto_reverse'>
Auto reverse transition video
</option>
<option value='separate_video'>
Use separate back-transition video
</option>
</select>
</div>
{transitionReverseMode === 'separate_video' && (
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Back transition video asset Target page
</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={reverseVideoUrl} value={targetPageSlug}
onChange={(event) => onChange={(event) => {
onChange('reverseVideoUrl', event.target.value) onChange('targetPageSlug', event.target.value);
} onChange('targetPageId', ''); // Clear legacy ID
}}
> >
<option value=''>Not selected</option> <option value=''>Not selected</option>
{addFallbackAssetOption( {pages
transitionVideoOptions, .filter((page) => page.id !== activePageId)
reverseVideoUrl, .map((page, index) => (
`Current back video · ${reverseVideoUrl}`, <option key={page.id} value={page.slug || ''}>
).map((option) => ( {page.name || `Page ${index + 1}`}
<option key={option.value} value={option.value}> </option>
{option.label} ))}
</option>
))}
</select> </select>
</div> </div>
)} )}
{/* CSS Transition Settings (when no video selected) */} {currentTargetMode === 'external_url' && (
{!transitionVideoUrl && ( <div>
<> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
<p className='mt-2 text-[11px] italic text-white/60'> External URL
No transition video selected. Configure CSS transition instead: </label>
</p> <input
<div> className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
<label className='mb-1 block text-[11px] font-semibold text-white/80'> placeholder='https://example.com'
Transition type value={externalUrl}
</label> onChange={(event) =>
<select onChange('externalUrl', event.target.value)
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' }
value={transitionType}
onChange={(event) =>
onChange('transitionType', event.target.value)
}
>
{CSS_TRANSITION_TYPES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Duration (ms)
</label>
<input
type='number'
min='0'
placeholder='Use project default'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionDurationMs}
onChange={(event) => {
const val = event.target.value;
onChange(
'transitionDurationMs',
val === '' ? '' : Math.max(0, parseInt(val, 10) || 0),
);
}}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Easing
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionEasing}
onChange={(event) =>
onChange('transitionEasing', event.target.value)
}
>
{CSS_EASING_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Overlay color
</label>
<div className='flex gap-2'>
<input
type='color'
className='h-7 w-10 cursor-pointer rounded border border-gray-300 p-0.5'
value={transitionOverlayColor || '#000000'}
onChange={(event) =>
onChange('transitionOverlayColor', event.target.value)
}
/>
<input
type='text'
placeholder='Use project default'
className='flex-1 rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionOverlayColor}
onChange={(event) =>
onChange('transitionOverlayColor', event.target.value)
}
/>
</div>
</div>
</>
)}
{transitionVideoUrl && (
<p className='text-[11px] text-white/60'>
Transition duration is set automatically from the selected video.
</p>
)}
{onPreviewTransition && (
<div className='flex gap-2 pt-1'>
<BaseButton
small
color='lightDark'
label='Preview Forward'
onClick={() => onPreviewTransition('forward')}
/>
<BaseButton
small
color='lightDark'
label='Preview Back'
onClick={() => onPreviewTransition('back')}
/> />
</div> </div>
)} )}
{currentTargetMode === 'target_page' && (
<>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Transition video asset
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionVideoUrl}
onChange={(event) => {
onChange('transitionVideoUrl', event.target.value);
}}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
transitionVideoOptions,
transitionVideoUrl,
`Current video · ${transitionVideoUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{selectedTransitionDurationNote && (
<p className='mt-1 text-[11px] text-white/60'>
{selectedTransitionDurationNote}
</p>
)}
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Back transition mode
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionReverseMode}
onChange={(event) =>
onChange(
'transitionReverseMode',
event.target.value === 'separate_video'
? 'separate_video'
: 'auto_reverse',
)
}
>
<option value='auto_reverse'>
Auto reverse transition video
</option>
<option value='separate_video'>
Use separate back-transition video
</option>
</select>
</div>
{transitionReverseMode === 'separate_video' && (
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Back transition video asset
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={reverseVideoUrl}
onChange={(event) =>
onChange('reverseVideoUrl', event.target.value)
}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
transitionVideoOptions,
reverseVideoUrl,
`Current back video · ${reverseVideoUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
{/* CSS Transition Settings (when no video selected) */}
{!transitionVideoUrl && (
<>
<p className='mt-2 text-[11px] italic text-white/60'>
No transition video selected. Configure CSS transition
instead:
</p>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Transition type
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionType}
onChange={(event) =>
onChange('transitionType', event.target.value)
}
>
{CSS_TRANSITION_TYPES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Duration (ms)
</label>
<input
type='number'
min='0'
placeholder='Use project default'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionDurationMs}
onChange={(event) => {
const val = event.target.value;
onChange(
'transitionDurationMs',
val === '' ? '' : Math.max(0, parseInt(val, 10) || 0),
);
}}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Easing
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionEasing}
onChange={(event) =>
onChange('transitionEasing', event.target.value)
}
>
{CSS_EASING_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Overlay color
</label>
<div className='flex gap-2'>
<input
type='color'
className='h-7 w-10 cursor-pointer rounded border border-gray-300 p-0.5'
value={transitionOverlayColor || '#000000'}
onChange={(event) =>
onChange('transitionOverlayColor', event.target.value)
}
/>
<input
type='text'
placeholder='Use project default'
className='flex-1 rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionOverlayColor}
onChange={(event) =>
onChange('transitionOverlayColor', event.target.value)
}
/>
</div>
</div>
</>
)}
{transitionVideoUrl && (
<p className='text-[11px] text-white/60'>
Transition duration is set automatically from the selected
video.
</p>
)}
</>
)}
</> </>
)} )}
</div> </div>

View File

@ -107,8 +107,10 @@ export interface NavigationSettingsSectionProps {
navLabelFontFamily: string; navLabelFontFamily: string;
navType: 'forward' | 'back'; navType: 'forward' | 'back';
navDisabled: boolean; navDisabled: boolean;
navigationTargetMode?: 'target_page' | 'external_url';
targetPageId: string; targetPageId: string;
targetPageSlug: string; targetPageSlug: string;
externalUrl?: string;
transitionVideoUrl: string; transitionVideoUrl: string;
transitionReverseMode: 'auto_reverse' | 'separate_video'; transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string; reverseVideoUrl: string;

View File

@ -109,8 +109,10 @@ interface FormState {
navLabelFontFamily: string; navLabelFontFamily: string;
navType: 'forward' | 'back'; navType: 'forward' | 'back';
navDisabled: boolean; navDisabled: boolean;
navigationTargetMode: 'target_page' | 'external_url';
targetPageId: string; targetPageId: string;
targetPageSlug: string; targetPageSlug: string;
externalUrl: string;
transitionVideoUrl: string; transitionVideoUrl: string;
transitionReverseMode: 'auto_reverse' | 'separate_video'; transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string; reverseVideoUrl: string;
@ -277,8 +279,10 @@ const initialState: FormState = {
navLabelFontFamily: '', navLabelFontFamily: '',
navType: 'forward', navType: 'forward',
navDisabled: false, navDisabled: false,
navigationTargetMode: 'target_page',
targetPageId: '', targetPageId: '',
targetPageSlug: '', targetPageSlug: '',
externalUrl: '',
transitionVideoUrl: '', transitionVideoUrl: '',
transitionReverseMode: 'auto_reverse', transitionReverseMode: 'auto_reverse',
reverseVideoUrl: '', reverseVideoUrl: '',
@ -458,8 +462,13 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
navLabelFontFamily: String(settings.navLabelFontFamily || ''), navLabelFontFamily: String(settings.navLabelFontFamily || ''),
navType: settings.navType === 'back' ? 'back' : 'forward', navType: settings.navType === 'back' ? 'back' : 'forward',
navDisabled: Boolean(settings.navDisabled), navDisabled: Boolean(settings.navDisabled),
navigationTargetMode:
settings.navigationTargetMode === 'external_url'
? 'external_url'
: 'target_page',
targetPageId: String(settings.targetPageId || ''), targetPageId: String(settings.targetPageId || ''),
targetPageSlug: String(settings.targetPageSlug || ''), targetPageSlug: String(settings.targetPageSlug || ''),
externalUrl: String(settings.externalUrl || ''),
transitionVideoUrl: String(settings.transitionVideoUrl || ''), transitionVideoUrl: String(settings.transitionVideoUrl || ''),
transitionReverseMode: transitionReverseMode:
settings.transitionReverseMode === 'separate_video' settings.transitionReverseMode === 'separate_video'
@ -977,8 +986,10 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
settings.navLabelFontFamily = state.navLabelFontFamily.trim(); settings.navLabelFontFamily = state.navLabelFontFamily.trim();
settings.navType = state.navType; settings.navType = state.navType;
settings.navDisabled = state.navDisabled; settings.navDisabled = state.navDisabled;
settings.navigationTargetMode = state.navigationTargetMode;
settings.targetPageId = state.targetPageId.trim(); settings.targetPageId = state.targetPageId.trim();
settings.targetPageSlug = state.targetPageSlug.trim(); settings.targetPageSlug = state.targetPageSlug.trim();
settings.externalUrl = state.externalUrl.trim();
settings.transitionVideoUrl = state.transitionVideoUrl.trim(); settings.transitionVideoUrl = state.transitionVideoUrl.trim();
settings.transitionReverseMode = state.transitionReverseMode; settings.transitionReverseMode = state.transitionReverseMode;
settings.reverseVideoUrl = state.reverseVideoUrl.trim(); settings.reverseVideoUrl = state.reverseVideoUrl.trim();

View File

@ -750,6 +750,16 @@ export default function RuntimePresentation({
return; return;
} }
if (
isNavigationType(element.type) &&
element.navigationTargetMode === 'external_url'
) {
if (element.externalUrl) {
handleInfoPanelOpenExternalUrl(element.externalUrl);
}
return;
}
// Get navigation context from hook for history-based back navigation // Get navigation context from hook for history-based back navigation
const navContext = getNavigationContext(); const navContext = getNavigationContext();
@ -799,6 +809,7 @@ export default function RuntimePresentation({
isBuffering, isBuffering,
getNavigationContext, getNavigationContext,
setCurrentElementTransitionSettings, setCurrentElementTransitionSettings,
handleInfoPanelOpenExternalUrl,
], ],
); );

View File

@ -188,9 +188,6 @@ export interface ConstructorContextValue {
selectedTransition: string; selectedTransition: string;
}; };
// Transition preview
onPreviewTransition: (direction: 'forward' | 'back') => void;
// Navigation settings // Navigation settings
allowedNavigationTypes: NavigationElementType[]; allowedNavigationTypes: NavigationElementType[];
normalizeNavigationType: ( normalizeNavigationType: (
@ -412,14 +409,12 @@ export function useConstructorNavigation() {
activePageId: ctx.activePageId, activePageId: ctx.activePageId,
allowedNavigationTypes: ctx.allowedNavigationTypes, allowedNavigationTypes: ctx.allowedNavigationTypes,
normalizeNavigationType: ctx.normalizeNavigationType, normalizeNavigationType: ctx.normalizeNavigationType,
onPreviewTransition: ctx.onPreviewTransition,
}), }),
[ [
ctx.pages, ctx.pages,
ctx.activePageId, ctx.activePageId,
ctx.allowedNavigationTypes, ctx.allowedNavigationTypes,
ctx.normalizeNavigationType, ctx.normalizeNavigationType,
ctx.onPreviewTransition,
], ],
); );
} }

View File

@ -211,6 +211,7 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial<
navLabel: 'Forward', navLabel: 'Forward',
navType: 'forward', navType: 'forward',
navDisabled: false, navDisabled: false,
navigationTargetMode: 'target_page',
iconUrl: '', iconUrl: '',
transitionReverseMode: 'auto_reverse', transitionReverseMode: 'auto_reverse',
}, },
@ -803,8 +804,14 @@ export const buildElementSettings = (
if (element.navDisabled !== undefined) { if (element.navDisabled !== undefined) {
settings.navDisabled = element.navDisabled; settings.navDisabled = element.navDisabled;
} }
addIfNotEmpty(
settings,
'navigationTargetMode',
element.navigationTargetMode,
);
addIfNotEmpty(settings, 'targetPageId', element.targetPageId); addIfNotEmpty(settings, 'targetPageId', element.targetPageId);
addIfNotEmpty(settings, 'targetPageSlug', element.targetPageSlug); addIfNotEmpty(settings, 'targetPageSlug', element.targetPageSlug);
addIfNotEmpty(settings, 'externalUrl', element.externalUrl);
addIfNotEmpty(settings, 'transitionVideoUrl', element.transitionVideoUrl); addIfNotEmpty(settings, 'transitionVideoUrl', element.transitionVideoUrl);
addIfNotEmpty( addIfNotEmpty(
settings, settings,

View File

@ -147,7 +147,9 @@ function collectTargetPageSlugs(element: Record<string, unknown>): string[] {
if (slug) slugs.add(slug); if (slug) slugs.add(slug);
}; };
addSlug(element.targetPageSlug); if (element.navigationTargetMode !== 'external_url') {
addSlug(element.targetPageSlug);
}
const sections = Array.isArray(element.infoPanelSections) const sections = Array.isArray(element.infoPanelSections)
? (element.infoPanelSections as Record<string, unknown>[]) ? (element.infoPanelSections as Record<string, unknown>[])

View File

@ -520,7 +520,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const { const {
preview: transitionPreview, preview: transitionPreview,
pendingPageId: pendingNavigationPageId, pendingPageId: pendingNavigationPageId,
openPreview: openTransitionPreviewForElement,
openPreviewWithTarget, openPreviewWithTarget,
closePreview: closeTransitionPreview, closePreview: closeTransitionPreview,
} = useTransitionPreview({ } = useTransitionPreview({
@ -1244,7 +1243,26 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
item && item.type && labelByType[item.type as CanvasElementType], item && item.type && labelByType[item.type as CanvasElementType],
) )
.map((item) => { .map((item) => {
const elementType = item.type as CanvasElementType; const navigationTargetMode =
item.navigationTargetMode === 'external_url'
? 'external_url'
: ('target_page' as const);
const rawElementType = item.type as CanvasElementType;
const elementType =
navigationTargetMode === 'external_url' &&
isNavigationElementType(rawElementType)
? 'navigation_next'
: rawElementType;
const navType =
navigationTargetMode === 'external_url'
? 'forward'
: item.navType === 'back' || item.navType === 'forward'
? item.navType
: isNavigationElementType(elementType)
? getNavigationButtonKind(
elementType as NavigationElementType,
)
: undefined;
const normalizedElement: CanvasElement = { const normalizedElement: CanvasElement = {
...item, ...item,
id: String(item.id || createLocalId()), id: String(item.id || createLocalId()),
@ -1411,21 +1429,21 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
? item.descriptionText ? item.descriptionText
: '', : '',
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '', navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
navType: navType,
item.navType === 'back' || item.navType === 'forward' navigationTargetMode,
? item.navType
: isNavigationElementType(elementType)
? getNavigationButtonKind(
elementType as NavigationElementType,
)
: undefined,
// Support both targetPageSlug (new) and targetPageId (legacy) // Support both targetPageSlug (new) and targetPageId (legacy)
targetPageSlug: targetPageSlug:
navigationTargetMode !== 'external_url' &&
typeof item.targetPageSlug === 'string' typeof item.targetPageSlug === 'string'
? item.targetPageSlug ? item.targetPageSlug
: '', : '',
targetPageId: targetPageId:
typeof item.targetPageId === 'string' ? item.targetPageId : '', navigationTargetMode !== 'external_url' &&
typeof item.targetPageId === 'string'
? item.targetPageId
: '',
externalUrl:
typeof item.externalUrl === 'string' ? item.externalUrl : '',
transitionVideoUrl: transitionVideoUrl:
typeof item.transitionVideoUrl === 'string' typeof item.transitionVideoUrl === 'string'
? item.transitionVideoUrl ? item.transitionVideoUrl
@ -1628,20 +1646,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
); );
}; };
// openTransitionPreviewForElement is now provided by useTransitionPreview hook
const openTransitionPreview = (direction: 'forward' | 'back') => {
if (
!selectedElement ||
(selectedElement.type !== 'navigation_next' &&
selectedElement.type !== 'navigation_prev')
) {
return;
}
openTransitionPreviewForElement(selectedElement, direction);
};
const onCanvasElementClick = (element: CanvasElement) => { const onCanvasElementClick = (element: CanvasElement) => {
if (!isConstructorEditMode) { if (!isConstructorEditMode) {
if (isNavigationElementType(element.type)) { if (isNavigationElementType(element.type)) {
@ -1656,6 +1660,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Cancel any pending fade to prevent stale fade state across navigations // Cancel any pending fade to prevent stale fade state across navigations
resetFadeIn(); resetFadeIn();
if (element.navigationTargetMode === 'external_url') {
if (element.externalUrl) {
handleInfoPanelOpenExternalUrl(element.externalUrl);
}
return;
}
// Use shared navigation helpers // Use shared navigation helpers
const direction = getNavigationDirection(element); const direction = getNavigationDirection(element);
@ -2088,9 +2099,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Duration notes // Duration notes
durationNotes, durationNotes,
// Transition preview
onPreviewTransition: openTransitionPreview,
// Navigation settings // Navigation settings
allowedNavigationTypes, allowedNavigationTypes,
normalizeNavigationType, normalizeNavigationType,
@ -2139,7 +2147,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
infoPanelSectionOps, infoPanelSectionOps,
getDuration, getDuration,
durationNotes, durationNotes,
openTransitionPreview,
allowedNavigationTypes, allowedNavigationTypes,
normalizeNavigationType, normalizeNavigationType,
saveConstructor, saveConstructor,

View File

@ -32,6 +32,11 @@ export type CanvasElementType =
*/ */
export type NavigationButtonKind = 'forward' | 'back'; export type NavigationButtonKind = 'forward' | 'back';
/**
* Navigation destination mode for forward buttons.
*/
export type NavigationTargetMode = 'target_page' | 'external_url';
/** /**
* Editor menu item type for constructor sidebar * Editor menu item type for constructor sidebar
*/ */
@ -400,10 +405,14 @@ export interface CanvasElement extends BaseCanvasElement {
navLabelFontFamily?: string; navLabelFontFamily?: string;
navType?: NavigationButtonKind; navType?: NavigationButtonKind;
navDisabled?: boolean; navDisabled?: boolean;
/** Destination mode for forward navigation buttons. Defaults to target_page. */
navigationTargetMode?: NavigationTargetMode;
/** @deprecated Use targetPageSlug instead - IDs change when copied between environments */ /** @deprecated Use targetPageSlug instead - IDs change when copied between environments */
targetPageId?: string; targetPageId?: string;
/** Target page slug for navigation - slugs are consistent across environments */ /** Target page slug for navigation - slugs are consistent across environments */
targetPageSlug?: string; targetPageSlug?: string;
/** External URL for navigation buttons when navigationTargetMode is external_url. */
externalUrl?: string;
transitionVideoUrl?: string; transitionVideoUrl?: string;
/** Storage key for the transition video (for cache lookup) */ /** Storage key for the transition video (for cache lookup) */
transitionStorageKey?: string; transitionStorageKey?: string;
@ -793,7 +802,6 @@ export interface EditorNavigationProps {
sort_order?: number; sort_order?: number;
}>; }>;
activePageId: string; activePageId: string;
onPreviewTransition: (direction: 'forward' | 'back') => void;
normalizeNavigationType: ( normalizeNavigationType: (
element: CanvasElement, element: CanvasElement,
nextType: 'navigation_next' | 'navigation_prev', nextType: 'navigation_next' | 'navigation_prev',

View File

@ -77,8 +77,10 @@ export interface PageDataLoaderResult {
export interface NavigableElement { export interface NavigableElement {
id: string; id: string;
type: string; type: string;
navigationTargetMode?: 'target_page' | 'external_url';
targetPageSlug?: string; targetPageSlug?: string;
targetPageId?: string; targetPageId?: string;
externalUrl?: string;
transitionVideoUrl?: string; transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video'; transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string; reverseVideoUrl?: string;