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>
|
</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}
|
||||||
|
|||||||
@ -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 || '',
|
||||||
|
|||||||
@ -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'>
|
||||||
|
|||||||
@ -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'>
|
||||||
|
|||||||
@ -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'>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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