fixed fullscreen mode issue and improved buttons opacity
This commit is contained in:
parent
ed59ac0e62
commit
11b230f9dd
@ -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}
|
||||
|
||||
@ -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 || '',
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
29
frontend/src/lib/opacityPercent.ts
Normal file
29
frontend/src/lib/opacityPercent.ts
Normal 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);
|
||||
Loading…
x
Reference in New Issue
Block a user