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> </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 */} {/* Save Button - reuse BaseButton with subtitle */}
<BaseButton <BaseButton
small small
color='info' color='info'
className='h-10 w-[86px]'
label={isSaving ? 'Saving...' : 'Save'} label={isSaving ? 'Saving...' : 'Save'}
subtitle={ subtitle={
lastSavedAt lastSavedAt
? dataFormatter.relativeTimestamp(lastSavedAt) ? dataFormatter.relativeTimestamp(lastSavedAt)
: undefined : ' '
} }
onClick={onSave} onClick={onSave}
disabled={isSaving} disabled={isSaving}
@ -445,11 +446,12 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
<BaseButton <BaseButton
small small
color='success' color='success'
className='h-10 w-[86px]'
label={isSavingToStage ? 'Saving...' : 'Stage'} label={isSavingToStage ? 'Saving...' : 'Stage'}
subtitle={ subtitle={
lastSavedToStageAt lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt) ? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: undefined : ' '
} }
onClick={onSaveToStage} onClick={onSaveToStage}
disabled={isSavingToStage} disabled={isSavingToStage}

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,10 @@
import React from 'react'; import React from 'react';
import type { StyleSettingsSectionProps } from './types'; import type { StyleSettingsSectionProps } from './types';
import { FONT_OPTIONS } from '../../lib/fonts'; import { FONT_OPTIONS } from '../../lib/fonts';
import {
opacityToPercentInput,
percentInputToOpacityValue,
} from '../../lib/opacityPercent';
const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
values, values,
@ -214,13 +218,19 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
</div> </div>
<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'>
Opacity Opacity (%)
</label> </label>
<input <input
type='number'
step='0.1'
min='0'
max='100'
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={values.opacity || ''} value={opacityToPercentInput(values.opacity)}
onChange={(e) => onChange('opacity', e.target.value)} onChange={(e) =>
placeholder='0..1' onChange('opacity', percentInputToOpacityValue(e.target.value))
}
placeholder='50'
/> />
</div> </div>
<div> <div>

View File

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