fixed fullscreen mode issue and improved buttons opacity

This commit is contained in:
Dmitri 2026-07-01 18:32:15 +02:00
parent ed59ac0e62
commit 11b230f9dd
8 changed files with 344 additions and 92 deletions

View File

@ -426,16 +426,17 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
</div>
</div>
<div className='flex h-[58px] items-start gap-2 border-l border-white/15 pt-4 pl-3'>
<div className='flex h-[58px] items-center gap-2 border-l border-white/15 pl-3'>
{/* Save Button - reuse BaseButton with subtitle */}
<BaseButton
small
color='info'
className='h-10 w-[86px]'
label={isSaving ? 'Saving...' : 'Save'}
subtitle={
lastSavedAt
? dataFormatter.relativeTimestamp(lastSavedAt)
: undefined
: ' '
}
onClick={onSave}
disabled={isSaving}
@ -445,11 +446,12 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
<BaseButton
small
color='success'
className='h-10 w-[86px]'
label={isSavingToStage ? 'Saving...' : 'Stage'}
subtitle={
lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: undefined
: ' '
}
onClick={onSaveToStage}
disabled={isSavingToStage}

View File

@ -55,6 +55,10 @@ import {
} from '../../types/uiControls';
import { FONT_OPTIONS } from '../../lib/fonts';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
import {
opacityToPercentInput,
percentInputToOpacityNumber,
} from '../../lib/opacityPercent';
type NavigationElementType = 'navigation_next' | 'navigation_prev';
@ -127,7 +131,7 @@ const handleCssPropertyChange = (
}
} else {
onUpdateElement({
[prop]: value || undefined,
[prop]: value === '' ? undefined : value,
});
}
};
@ -472,7 +476,7 @@ export function ElementEditorPanel({
['Icon color', 'color'],
['Default border', 'defaultBorderColor'],
['Active border', 'activeBorderColor'],
['Opacity', 'opacity'],
['Opacity (%)', 'opacity'],
['Shadow', 'boxShadow'],
].map(([label, key]) => (
<div key={key}>
@ -480,17 +484,27 @@ export function ElementEditorPanel({
{label}
</label>
<input
type={key === 'opacity' ? 'number' : 'text'}
step={key === 'opacity' ? '0.1' : undefined}
min={key === 'opacity' ? '0' : undefined}
max={key === 'opacity' ? '100' : undefined}
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={String(
selectedSystemControlSettings[
key as keyof typeof selectedSystemControlSettings
] ?? '',
)}
value={
key === 'opacity'
? opacityToPercentInput(
selectedSystemControlSettings.opacity,
)
: String(
selectedSystemControlSettings[
key as keyof typeof selectedSystemControlSettings
] ?? '',
)
}
onChange={(event) =>
updateSelectedSystemControl({
[key]:
key === 'opacity'
? Number(event.target.value) || 0
? percentInputToOpacityNumber(event.target.value)
: event.target.value,
} as Partial<SystemUiControlSettings>)
}
@ -1085,7 +1099,7 @@ export function ElementEditorPanel({
borderRadius: extractNumericValue(
selectedElement.borderRadius,
),
opacity: selectedElement.opacity || '',
opacity: selectedElement.opacity ?? '',
boxShadow: selectedElement.boxShadow || '',
display: selectedElement.display || '',
position: selectedElement.position || '',
@ -1973,7 +1987,7 @@ export function ElementEditorPanel({
borderRadius: extractNumericValue(
selectedElement.borderRadius,
),
opacity: selectedElement.opacity || '',
opacity: selectedElement.opacity ?? '',
boxShadow: selectedElement.boxShadow || '',
display: selectedElement.display || '',
position: selectedElement.position || '',

View File

@ -8,6 +8,10 @@
import React from 'react';
import FormField from '../FormField';
import type { EffectsSettingsSectionProps } from './types';
import {
opacityToPercentInput,
percentInputToOpacityValue,
} from '../../lib/opacityPercent';
const EffectsSettingsSection: React.FC<EffectsSettingsSectionProps> = ({
values,
@ -77,11 +81,20 @@ const EffectsSettingsSection: React.FC<EffectsSettingsSectionProps> = ({
placeholder='e.g. 1.05'
/>
</FormField>
<FormField label='Opacity'>
<FormField label='Opacity (%)'>
<input
value={values.hoverOpacity || ''}
onChange={(e) => onChange('hoverOpacity', e.target.value)}
placeholder='0..1'
type='number'
step='0.1'
min='0'
max='100'
value={opacityToPercentInput(values.hoverOpacity)}
onChange={(e) =>
onChange(
'hoverOpacity',
percentInputToOpacityValue(e.target.value),
)
}
placeholder='50'
/>
</FormField>
<FormField label='Background color'>
@ -167,23 +180,37 @@ const EffectsSettingsSection: React.FC<EffectsSettingsSectionProps> = ({
Persist visibility
</label>
</FormField>
<FormField label='Initial opacity'>
<FormField label='Initial opacity (%)'>
<input
value={values.hoverRevealInitialOpacity || ''}
type='number'
step='0.1'
min='0'
max='100'
value={opacityToPercentInput(values.hoverRevealInitialOpacity)}
onChange={(e) =>
onChange('hoverRevealInitialOpacity', e.target.value)
onChange(
'hoverRevealInitialOpacity',
percentInputToOpacityValue(e.target.value),
)
}
placeholder='0'
disabled={values.hoverReveal !== 'true'}
/>
</FormField>
<FormField label='Target opacity'>
<FormField label='Target opacity (%)'>
<input
value={values.hoverRevealTargetOpacity || ''}
type='number'
step='0.1'
min='0'
max='100'
value={opacityToPercentInput(values.hoverRevealTargetOpacity)}
onChange={(e) =>
onChange('hoverRevealTargetOpacity', e.target.value)
onChange(
'hoverRevealTargetOpacity',
percentInputToOpacityValue(e.target.value),
)
}
placeholder='1'
placeholder='100'
disabled={values.hoverReveal !== 'true'}
/>
</FormField>
@ -237,11 +264,20 @@ const EffectsSettingsSection: React.FC<EffectsSettingsSectionProps> = ({
placeholder='e.g. 0 0 0 3px rgba(...)'
/>
</FormField>
<FormField label='Opacity'>
<FormField label='Opacity (%)'>
<input
value={values.focusOpacity || ''}
onChange={(e) => onChange('focusOpacity', e.target.value)}
placeholder='0..1'
type='number'
step='0.1'
min='0'
max='100'
value={opacityToPercentInput(values.focusOpacity)}
onChange={(e) =>
onChange(
'focusOpacity',
percentInputToOpacityValue(e.target.value),
)
}
placeholder='50'
/>
</FormField>
</div>
@ -261,11 +297,20 @@ const EffectsSettingsSection: React.FC<EffectsSettingsSectionProps> = ({
placeholder='e.g. 0.95'
/>
</FormField>
<FormField label='Opacity'>
<FormField label='Opacity (%)'>
<input
value={values.activeOpacity || ''}
onChange={(e) => onChange('activeOpacity', e.target.value)}
placeholder='0..1'
type='number'
step='0.1'
min='0'
max='100'
value={opacityToPercentInput(values.activeOpacity)}
onChange={(e) =>
onChange(
'activeOpacity',
percentInputToOpacityValue(e.target.value),
)
}
placeholder='50'
/>
</FormField>
<FormField label='Background color'>

View File

@ -8,6 +8,10 @@
import React from 'react';
import type { EffectsSettingsFormValues } from './types';
import type { CanvasElementType, AssetOption } from '../../types/constructor';
import {
opacityToPercentInput,
percentInputToOpacityValue,
} from '../../lib/opacityPercent';
interface EffectsSettingsSectionCompactProps {
values: EffectsSettingsFormValues;
@ -98,13 +102,22 @@ const EffectsSettingsSectionCompact: React.FC<
</div>
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Opacity
Opacity (%)
</label>
<input
type='number'
step='0.1'
min='0'
max='100'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.hoverOpacity || ''}
onChange={(e) => onChange('hoverOpacity', e.target.value)}
placeholder='0..1'
value={opacityToPercentInput(values.hoverOpacity)}
onChange={(e) =>
onChange(
'hoverOpacity',
percentInputToOpacityValue(e.target.value),
)
}
placeholder='50'
/>
</div>
<div>
@ -209,13 +222,20 @@ const EffectsSettingsSectionCompact: React.FC<
</div>
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Initial Opacity
Initial Opacity (%)
</label>
<input
type='number'
step='0.1'
min='0'
max='100'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.hoverRevealInitialOpacity || ''}
value={opacityToPercentInput(values.hoverRevealInitialOpacity)}
onChange={(e) =>
onChange('hoverRevealInitialOpacity', e.target.value)
onChange(
'hoverRevealInitialOpacity',
percentInputToOpacityValue(e.target.value),
)
}
placeholder='0'
disabled={values.hoverReveal !== 'true'}
@ -223,15 +243,22 @@ const EffectsSettingsSectionCompact: React.FC<
</div>
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Target Opacity
Target Opacity (%)
</label>
<input
type='number'
step='0.1'
min='0'
max='100'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.hoverRevealTargetOpacity || ''}
value={opacityToPercentInput(values.hoverRevealTargetOpacity)}
onChange={(e) =>
onChange('hoverRevealTargetOpacity', e.target.value)
onChange(
'hoverRevealTargetOpacity',
percentInputToOpacityValue(e.target.value),
)
}
placeholder='1'
placeholder='100'
disabled={values.hoverReveal !== 'true'}
/>
</div>
@ -295,13 +322,22 @@ const EffectsSettingsSectionCompact: React.FC<
</div>
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Opacity
Opacity (%)
</label>
<input
type='number'
step='0.1'
min='0'
max='100'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.focusOpacity || ''}
onChange={(e) => onChange('focusOpacity', e.target.value)}
placeholder='0..1'
value={opacityToPercentInput(values.focusOpacity)}
onChange={(e) =>
onChange(
'focusOpacity',
percentInputToOpacityValue(e.target.value),
)
}
placeholder='50'
/>
</div>
<div>
@ -348,13 +384,22 @@ const EffectsSettingsSectionCompact: React.FC<
</div>
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Opacity
Opacity (%)
</label>
<input
type='number'
step='0.1'
min='0'
max='100'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.activeOpacity || ''}
onChange={(e) => onChange('activeOpacity', e.target.value)}
placeholder='0..1'
value={opacityToPercentInput(values.activeOpacity)}
onChange={(e) =>
onChange(
'activeOpacity',
percentInputToOpacityValue(e.target.value),
)
}
placeholder='50'
/>
</div>
<div className='col-span-2'>

View File

@ -8,6 +8,10 @@
import React from 'react';
import FormField from '../FormField';
import type { StyleSettingsSectionProps } from './types';
import {
opacityToPercentInput,
percentInputToOpacityValue,
} from '../../lib/opacityPercent';
const StyleSettingsSection: React.FC<StyleSettingsSectionProps> = ({
values,
@ -136,11 +140,17 @@ const StyleSettingsSection: React.FC<StyleSettingsSectionProps> = ({
placeholder='empty = 0'
/>
</FormField>
<FormField label='Opacity'>
<FormField label='Opacity (%)'>
<input
value={values.opacity || ''}
onChange={(event) => onChange('opacity', event.target.value)}
placeholder='0..1'
type='number'
step='0.1'
min='0'
max='100'
value={opacityToPercentInput(values.opacity)}
onChange={(event) =>
onChange('opacity', percentInputToOpacityValue(event.target.value))
}
placeholder='50'
/>
</FormField>
<FormField label='Shadow'>

View File

@ -8,6 +8,10 @@
import React from 'react';
import type { StyleSettingsSectionProps } from './types';
import { FONT_OPTIONS } from '../../lib/fonts';
import {
opacityToPercentInput,
percentInputToOpacityValue,
} from '../../lib/opacityPercent';
const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
values,
@ -214,13 +218,19 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Opacity
Opacity (%)
</label>
<input
type='number'
step='0.1'
min='0'
max='100'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.opacity || ''}
onChange={(e) => onChange('opacity', e.target.value)}
placeholder='0..1'
value={opacityToPercentInput(values.opacity)}
onChange={(e) =>
onChange('opacity', percentInputToOpacityValue(e.target.value))
}
placeholder='50'
/>
</div>
<div>

View File

@ -13,6 +13,7 @@ import React, {
useRef,
useMemo,
} from 'react';
import { createPortal } from 'react-dom';
import type { CanvasElement, InfoPanelImage } from '../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../lib/fonts';
@ -31,6 +32,72 @@ interface ImageDetailPanelProps {
onDetailPositionChange?: (xPercent: number, yPercent: number) => void;
}
const getFullscreenElement = (): Element | null => {
return (
document.fullscreenElement ??
(
document as Document & {
webkitFullscreenElement?: Element | null;
}
).webkitFullscreenElement ??
null
);
};
const isNativeFullscreenElement = (element: Element | null): boolean =>
!!element && getFullscreenElement() === element;
const exitNativeFullscreen = async (): Promise<void> => {
if (document.exitFullscreen) {
await document.exitFullscreen();
return;
}
const webkitExitFullscreen = (
document as Document & {
webkitExitFullscreen?: () => Promise<void>;
}
).webkitExitFullscreen;
if (webkitExitFullscreen) {
await webkitExitFullscreen.call(document);
}
};
const requestEmbeddingFrameFullscreen = async (): Promise<boolean> => {
if (window.parent === window) return false;
try {
const frame = window.frameElement as
| (HTMLElement & {
webkitRequestFullscreen?: () => Promise<void>;
})
| null;
if (frame?.requestFullscreen) {
await frame.requestFullscreen();
return true;
}
if (frame?.webkitRequestFullscreen) {
await frame.webkitRequestFullscreen();
return true;
}
} catch {
// Cross-origin parents are not directly accessible from the iframe.
}
window.parent.postMessage(
{
type: 'tour-builder:request-fullscreen',
source: 'image-detail-panel',
},
'*',
);
return false;
};
const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
element,
image,
@ -49,6 +116,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
const [isLoading, setIsLoading] = useState(true);
const [embedError, setEmbedError] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
// Drag state for edit mode
const [isDragging, setIsDragging] = useState(false);
@ -61,6 +129,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
// Fade in animation
useEffect(() => {
setIsMounted(true);
requestAnimationFrame(() => setIsVisible(true));
}, []);
@ -68,7 +137,15 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
// (fullscreen mode handles ESC itself to exit fullscreen first)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !document.fullscreenElement) {
if (e.key !== 'Escape') return;
if (isFullscreen && !isNativeFullscreenElement(panelRef.current)) {
e.stopPropagation();
setIsFullscreen(false);
return;
}
if (!getFullscreenElement()) {
e.stopPropagation();
onClose();
}
@ -76,19 +153,20 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
}, [isFullscreen, onClose]);
// Focus the panel on mount
useEffect(() => {
if (!isMounted) return;
const panel = panelRef.current;
if (!panel) return;
panel.focus();
}, []);
}, [isMounted]);
// Handle fullscreen change events (user may exit via ESC or browser controls)
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
setIsFullscreen(isNativeFullscreenElement(panelRef.current));
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
@ -108,8 +186,16 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
const panel = panelRef.current;
if (!panel) return;
try {
if (!document.fullscreenElement) {
const nativeFullscreenElement = getFullscreenElement();
const isPanelNativeFullscreen = nativeFullscreenElement === panel;
if (!isFullscreen) {
if (nativeFullscreenElement && !isPanelNativeFullscreen) {
setIsFullscreen(true);
return;
}
try {
// Enter fullscreen
if (panel.requestFullscreen) {
await panel.requestFullscreen();
@ -127,55 +213,58 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
}
).webkitRequestFullscreen();
}
} else {
// Exit fullscreen
if (document.exitFullscreen) {
await document.exitFullscreen();
} else if (
(
document as Document & {
webkitExitFullscreen?: () => Promise<void>;
}
).webkitExitFullscreen
) {
// Safari fallback
await (
document as Document & { webkitExitFullscreen: () => Promise<void> }
).webkitExitFullscreen();
}
} catch {
// Fullscreen can be blocked inside iframes. Try to fullscreen the
// embedding iframe before falling back to filling the iframe viewport.
await requestEmbeddingFrameFullscreen();
} finally {
setIsFullscreen(true);
}
} catch {
// Fullscreen may be blocked or not supported (iOS Safari) - silently ignore
return;
}
}, []);
if (isPanelNativeFullscreen) {
try {
await exitNativeFullscreen();
} catch {
// Ignore errors and keep the local visual state in sync below.
}
}
setIsFullscreen(false);
}, [isFullscreen]);
// Handle backdrop click (disabled in edit mode)
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isEditMode) {
// Exit fullscreen first if active, then close
if (document.fullscreenElement) {
if (isNativeFullscreenElement(panelRef.current)) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
document.exitFullscreen().catch(() => {});
exitNativeFullscreen().catch(() => {});
} else if (isFullscreen) {
setIsFullscreen(false);
}
onClose();
}
},
[onClose, isEditMode],
[onClose, isEditMode, isFullscreen],
);
// Handle close with fullscreen exit
const handleClose = useCallback(async () => {
// Exit fullscreen before closing if in fullscreen mode
if (document.fullscreenElement) {
if (isNativeFullscreenElement(panelRef.current)) {
try {
await document.exitFullscreen();
await exitNativeFullscreen();
} catch {
// Ignore errors
}
} else if (isFullscreen) {
setIsFullscreen(false);
}
onClose();
}, [onClose]);
}, [isFullscreen, onClose]);
// Extract detail panel styling from element
const detailXPercent = element.detailXPercent ?? 75;
@ -208,6 +297,8 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
const embedSrc = isValidEmbed ? buildChromeFreeEmbedUrl(embedUrl) : '';
const isVideo = image?.itemType === 'video' && image?.videoUrl;
const hasImage = !!image;
const detailLayerZIndex = isFullscreen ? 1200 : undefined;
const detailBackdropZIndex = isFullscreen ? 1199 : undefined;
// Convert numeric values to canvas units for responsive scaling
const toCU = (value: string): string => {
@ -240,7 +331,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
overflow: 'hidden',
opacity: 1,
border: 'none',
zIndex: 9999,
zIndex: detailLayerZIndex,
}
: {
position: 'absolute',
@ -343,7 +434,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
};
}, [isDragging, onDetailPositionChange]);
return (
const content = (
<>
{/* Click backdrop (transparent) - InfoPanelOverlay provides the visual backdrop */}
<div
@ -351,6 +442,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
style={{
backgroundColor: 'transparent',
pointerEvents: isEditMode ? 'none' : 'auto',
zIndex: detailBackdropZIndex,
}}
onClick={handleBackdropClick}
/>
@ -362,6 +454,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
style={{
...cssVars,
...(letterboxStyles || { position: 'absolute', inset: 0 }),
zIndex: detailLayerZIndex,
}}
>
{/* Detail Panel */}
@ -613,6 +706,10 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
</div>
</>
);
if (!isMounted) return null;
return createPortal(content, document.body);
};
export default ImageDetailPanel;

View File

@ -0,0 +1,29 @@
const clampPercent = (value: number): number =>
Math.min(100, Math.max(0, value));
const formatNumber = (value: number): string =>
Number(value.toFixed(4)).toString();
export const opacityToPercentInput = (
value: string | number | null | undefined,
): string => {
if (value === null || value === undefined || value === '') return '';
const opacity = Number(value);
if (!Number.isFinite(opacity)) return '';
return formatNumber(clampPercent(opacity * 100));
};
export const percentInputToOpacityValue = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return '';
const percent = Number(trimmed);
if (!Number.isFinite(percent)) return '';
return formatNumber(clampPercent(percent) / 100);
};
export const percentInputToOpacityNumber = (value: string): number =>
Number(percentInputToOpacityValue(value) || 0);