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
*/
static isForwardElementWithTarget(element) {
if (element.navigationTargetMode === 'external_url') {
return false;
}
const isForward =
element.type === 'navigation_next' ||
(element.type?.startsWith?.('navigation') &&

View File

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

View File

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

View File

@ -6,10 +6,10 @@
*/
import React from 'react';
import BaseButton from '../BaseButton';
import type {
AssetOption,
NavigationButtonKind,
NavigationTargetMode,
CanvasElementType,
} from '../../types/constructor';
import type { TransitionType, EasingFunction } from '../../types/transition';
@ -42,7 +42,9 @@ interface NavigationSettingsSectionCompactProps {
navLabelFontFamily: string;
navDisabled: boolean;
iconUrl: string;
navigationTargetMode: NavigationTargetMode;
targetPageSlug: string;
externalUrl: string;
transitionVideoUrl: string;
transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string;
@ -67,11 +69,14 @@ interface NavigationSettingsSectionCompactProps {
| Partial<{
type: NavigationElementType;
navType: NavigationButtonKind;
navigationTargetMode: NavigationTargetMode;
label?: string;
navLabel?: string;
targetPageSlug?: string;
targetPageId?: string;
externalUrl?: string;
}>,
) => void;
onPreviewTransition?: (direction: 'forward' | 'back') => void;
}
const NavigationSettingsSectionCompact: React.FC<
@ -83,7 +88,9 @@ const NavigationSettingsSectionCompact: React.FC<
navLabelFontFamily,
navDisabled,
iconUrl,
navigationTargetMode,
targetPageSlug,
externalUrl,
transitionVideoUrl,
transitionReverseMode,
reverseVideoUrl,
@ -99,10 +106,13 @@ const NavigationSettingsSectionCompact: React.FC<
selectedMediaDurationNote,
selectedTransitionDurationNote,
onChange,
onPreviewTransition,
}) => {
const currentTargetMode: NavigationTargetMode =
navigationTargetMode === 'external_url' ? 'external_url' : 'target_page';
const currentKind: NavigationButtonKind =
navType || (type === 'navigation_prev' ? 'back' : 'forward');
currentTargetMode === 'external_url'
? 'forward'
: navType || (type === 'navigation_prev' ? 'back' : 'forward');
return (
<div className='space-y-2'>
@ -121,7 +131,15 @@ const NavigationSettingsSectionCompact: React.FC<
const nextType = allowedNavigationTypes.includes(requestedType)
? requestedType
: 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
@ -221,217 +239,252 @@ const NavigationSettingsSectionCompact: React.FC<
<>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Target page
Destination
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={targetPageSlug}
value={currentTargetMode}
onChange={(event) => {
onChange('targetPageSlug', event.target.value);
onChange('targetPageId', ''); // Clear legacy ID
const nextMode: NavigationTargetMode =
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>
{pages
.filter((page) => page.id !== activePageId)
.map((page, index) => (
<option key={page.id} value={page.slug || ''}>
{page.name || `Page ${index + 1}`}
</option>
))}
<option value='target_page'>Target page</option>
<option value='external_url'>External URL</option>
</select>
</div>
<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' && (
{currentTargetMode === 'target_page' && (
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Back transition video asset
Target page
</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)
}
value={targetPageSlug}
onChange={(event) => {
onChange('targetPageSlug', event.target.value);
onChange('targetPageId', ''); // Clear legacy ID
}}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
transitionVideoOptions,
reverseVideoUrl,
`Current back video · ${reverseVideoUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
{pages
.filter((page) => page.id !== activePageId)
.map((page, index) => (
<option key={page.id} value={page.slug || ''}>
{page.name || `Page ${index + 1}`}
</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>
)}
{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')}
{currentTargetMode === 'external_url' && (
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
External URL
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='https://example.com'
value={externalUrl}
onChange={(event) =>
onChange('externalUrl', event.target.value)
}
/>
</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>

View File

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

View File

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

View File

@ -750,6 +750,16 @@ export default function RuntimePresentation({
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
const navContext = getNavigationContext();
@ -799,6 +809,7 @@ export default function RuntimePresentation({
isBuffering,
getNavigationContext,
setCurrentElementTransitionSettings,
handleInfoPanelOpenExternalUrl,
],
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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