From 11b230f9ddf4ff5951391664be6e729c641b67aa Mon Sep 17 00:00:00 2001 From: Dmitri Date: Wed, 1 Jul 2026 18:32:15 +0200 Subject: [PATCH] fixed fullscreen mode issue and improved buttons opacity --- .../Constructor/ConstructorToolbar.tsx | 8 +- .../Constructor/ElementEditorPanel.tsx | 34 ++-- .../EffectsSettingsSection.tsx | 83 +++++++-- .../EffectsSettingsSectionCompact.tsx | 83 +++++++-- .../ElementSettings/StyleSettingsSection.tsx | 18 +- .../StyleSettingsSectionCompact.tsx | 18 +- .../UiElements/ImageDetailPanel.tsx | 163 ++++++++++++++---- frontend/src/lib/opacityPercent.ts | 29 ++++ 8 files changed, 344 insertions(+), 92 deletions(-) create mode 100644 frontend/src/lib/opacityPercent.ts diff --git a/frontend/src/components/Constructor/ConstructorToolbar.tsx b/frontend/src/components/Constructor/ConstructorToolbar.tsx index 426a0c6..5af156a 100644 --- a/frontend/src/components/Constructor/ConstructorToolbar.tsx +++ b/frontend/src/components/Constructor/ConstructorToolbar.tsx @@ -426,16 +426,17 @@ const ConstructorToolbar = forwardRef( -
+
{/* Save Button - reuse BaseButton with subtitle */} ( (
@@ -480,17 +484,27 @@ export function ElementEditorPanel({ {label} updateSelectedSystemControl({ [key]: key === 'opacity' - ? Number(event.target.value) || 0 + ? percentInputToOpacityNumber(event.target.value) : event.target.value, } as Partial) } @@ -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 || '', diff --git a/frontend/src/components/ElementSettings/EffectsSettingsSection.tsx b/frontend/src/components/ElementSettings/EffectsSettingsSection.tsx index a8539ee..f366f2b 100644 --- a/frontend/src/components/ElementSettings/EffectsSettingsSection.tsx +++ b/frontend/src/components/ElementSettings/EffectsSettingsSection.tsx @@ -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 = ({ values, @@ -77,11 +81,20 @@ const EffectsSettingsSection: React.FC = ({ placeholder='e.g. 1.05' /> - + 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' /> @@ -167,23 +180,37 @@ const EffectsSettingsSection: React.FC = ({ Persist visibility - + - onChange('hoverRevealInitialOpacity', e.target.value) + onChange( + 'hoverRevealInitialOpacity', + percentInputToOpacityValue(e.target.value), + ) } placeholder='0' disabled={values.hoverReveal !== 'true'} /> - + - onChange('hoverRevealTargetOpacity', e.target.value) + onChange( + 'hoverRevealTargetOpacity', + percentInputToOpacityValue(e.target.value), + ) } - placeholder='1' + placeholder='100' disabled={values.hoverReveal !== 'true'} /> @@ -237,11 +264,20 @@ const EffectsSettingsSection: React.FC = ({ placeholder='e.g. 0 0 0 3px rgba(...)' /> - + 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' />
@@ -261,11 +297,20 @@ const EffectsSettingsSection: React.FC = ({ placeholder='e.g. 0.95' /> - + 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' /> diff --git a/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx b/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx index 721758a..02cb35b 100644 --- a/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx +++ b/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx @@ -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<
onChange('hoverOpacity', e.target.value)} - placeholder='0..1' + value={opacityToPercentInput(values.hoverOpacity)} + onChange={(e) => + onChange( + 'hoverOpacity', + percentInputToOpacityValue(e.target.value), + ) + } + placeholder='50' />
@@ -209,13 +222,20 @@ const EffectsSettingsSectionCompact: React.FC<
- 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<
- onChange('hoverRevealTargetOpacity', e.target.value) + onChange( + 'hoverRevealTargetOpacity', + percentInputToOpacityValue(e.target.value), + ) } - placeholder='1' + placeholder='100' disabled={values.hoverReveal !== 'true'} />
@@ -295,13 +322,22 @@ const EffectsSettingsSectionCompact: React.FC<
onChange('focusOpacity', e.target.value)} - placeholder='0..1' + value={opacityToPercentInput(values.focusOpacity)} + onChange={(e) => + onChange( + 'focusOpacity', + percentInputToOpacityValue(e.target.value), + ) + } + placeholder='50' />
@@ -348,13 +384,22 @@ const EffectsSettingsSectionCompact: React.FC<
onChange('activeOpacity', e.target.value)} - placeholder='0..1' + value={opacityToPercentInput(values.activeOpacity)} + onChange={(e) => + onChange( + 'activeOpacity', + percentInputToOpacityValue(e.target.value), + ) + } + placeholder='50' />
diff --git a/frontend/src/components/ElementSettings/StyleSettingsSection.tsx b/frontend/src/components/ElementSettings/StyleSettingsSection.tsx index ced5605..3fecaf4 100644 --- a/frontend/src/components/ElementSettings/StyleSettingsSection.tsx +++ b/frontend/src/components/ElementSettings/StyleSettingsSection.tsx @@ -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 = ({ values, @@ -136,11 +140,17 @@ const StyleSettingsSection: React.FC = ({ placeholder='empty = 0' /> - + 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' /> diff --git a/frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx b/frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx index f0cc40b..6612e97 100644 --- a/frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx +++ b/frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx @@ -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 = ({ values, @@ -214,13 +218,19 @@ const StyleSettingsSectionCompact: React.FC = ({
onChange('opacity', e.target.value)} - placeholder='0..1' + value={opacityToPercentInput(values.opacity)} + onChange={(e) => + onChange('opacity', percentInputToOpacityValue(e.target.value)) + } + placeholder='50' />
diff --git a/frontend/src/components/UiElements/ImageDetailPanel.tsx b/frontend/src/components/UiElements/ImageDetailPanel.tsx index c8927dd..19fbca8 100644 --- a/frontend/src/components/UiElements/ImageDetailPanel.tsx +++ b/frontend/src/components/UiElements/ImageDetailPanel.tsx @@ -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 => { + if (document.exitFullscreen) { + await document.exitFullscreen(); + return; + } + + const webkitExitFullscreen = ( + document as Document & { + webkitExitFullscreen?: () => Promise; + } + ).webkitExitFullscreen; + + if (webkitExitFullscreen) { + await webkitExitFullscreen.call(document); + } +}; + +const requestEmbeddingFrameFullscreen = async (): Promise => { + if (window.parent === window) return false; + + try { + const frame = window.frameElement as + | (HTMLElement & { + webkitRequestFullscreen?: () => Promise; + }) + | 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 = ({ element, image, @@ -49,6 +116,7 @@ const ImageDetailPanel: React.FC = ({ 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 = ({ // Fade in animation useEffect(() => { + setIsMounted(true); requestAnimationFrame(() => setIsVisible(true)); }, []); @@ -68,7 +137,15 @@ const ImageDetailPanel: React.FC = ({ // (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 = ({ 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 = ({ 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 = ({ } ).webkitRequestFullscreen(); } - } else { - // Exit fullscreen - if (document.exitFullscreen) { - await document.exitFullscreen(); - } else if ( - ( - document as Document & { - webkitExitFullscreen?: () => Promise; - } - ).webkitExitFullscreen - ) { - // Safari fallback - await ( - document as Document & { webkitExitFullscreen: () => Promise } - ).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 = ({ 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 = ({ overflow: 'hidden', opacity: 1, border: 'none', - zIndex: 9999, + zIndex: detailLayerZIndex, } : { position: 'absolute', @@ -343,7 +434,7 @@ const ImageDetailPanel: React.FC = ({ }; }, [isDragging, onDetailPositionChange]); - return ( + const content = ( <> {/* Click backdrop (transparent) - InfoPanelOverlay provides the visual backdrop */}
= ({ style={{ backgroundColor: 'transparent', pointerEvents: isEditMode ? 'none' : 'auto', + zIndex: detailBackdropZIndex, }} onClick={handleBackdropClick} /> @@ -362,6 +454,7 @@ const ImageDetailPanel: React.FC = ({ style={{ ...cssVars, ...(letterboxStyles || { position: 'absolute', inset: 0 }), + zIndex: detailLayerZIndex, }} > {/* Detail Panel */} @@ -613,6 +706,10 @@ const ImageDetailPanel: React.FC = ({
); + + if (!isMounted) return null; + + return createPortal(content, document.body); }; export default ImageDetailPanel; diff --git a/frontend/src/lib/opacityPercent.ts b/frontend/src/lib/opacityPercent.ts new file mode 100644 index 0000000..7be3ec8 --- /dev/null +++ b/frontend/src/lib/opacityPercent.ts @@ -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);