Added elements cop/pase functionality
This commit is contained in:
parent
cd588bac8b
commit
490dd98e52
@ -20,6 +20,7 @@ import {
|
||||
mdiChevronRight,
|
||||
mdiChevronUp,
|
||||
mdiContentDuplicate,
|
||||
mdiContentPaste,
|
||||
mdiMusicNote,
|
||||
mdiVideo,
|
||||
mdiInformationOutline,
|
||||
@ -54,6 +55,10 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
onSelectMenuItem,
|
||||
allowedNavigationTypes,
|
||||
onAddElement,
|
||||
onCopyElement,
|
||||
onPasteElement,
|
||||
canCopyElement = false,
|
||||
canPasteElement = false,
|
||||
onCreatePage,
|
||||
isCreatingPage,
|
||||
onSave,
|
||||
@ -111,6 +116,8 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
!isDeletingPage &&
|
||||
!isReorderingPages &&
|
||||
activePageIndex >= 0;
|
||||
const canCopyCurrentElement = Boolean(onCopyElement) && canCopyElement;
|
||||
const canPasteCurrentElement = Boolean(onPasteElement) && canPasteElement;
|
||||
|
||||
// Keyboard handling (Escape closes dropdown)
|
||||
useEffect(() => {
|
||||
@ -137,7 +144,13 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
|
||||
// Shared button styles
|
||||
const triggerBtnClass =
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded text-sm font-medium text-white/90 hover:bg-white/20 transition-colors';
|
||||
'flex h-10 items-center gap-1.5 rounded px-3 text-sm font-medium text-white/90 transition-colors hover:bg-white/20';
|
||||
const iconBtnClass =
|
||||
'flex h-10 w-10 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35';
|
||||
const sectionClass =
|
||||
'flex h-[58px] flex-col justify-center gap-1 border-x border-white/15 px-3';
|
||||
const sectionLabelClass =
|
||||
'text-center text-[9px] font-semibold uppercase leading-none tracking-wide text-white/50';
|
||||
const dropdownPanelClass =
|
||||
'absolute top-full left-0 mt-1 min-w-[180px] py-1 rounded-lg bg-white/50 backdrop-blur-xl border border-white/30 shadow-lg z-10 flex flex-col items-start';
|
||||
|
||||
@ -173,7 +186,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className='fixed z-[1000] flex items-center gap-2 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl max-w-[95vw]'
|
||||
className='fixed z-[1000] flex min-h-[72px] items-center gap-2 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl max-w-[95vw]'
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
@ -184,253 +197,288 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
<BaseIcon path={mdiDotsVertical} size={24} />
|
||||
</div>
|
||||
|
||||
{/* Page Selector - reuse existing component */}
|
||||
<PageSelector
|
||||
pages={pages}
|
||||
activePageId={activePageId}
|
||||
onPageChange={onPageChange}
|
||||
disabled={isReorderingPages}
|
||||
/>
|
||||
|
||||
<div className='flex items-center gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => onMovePage?.('up')}
|
||||
disabled={!canMovePageUp}
|
||||
className='flex h-9 w-9 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35'
|
||||
title='Move page up'
|
||||
aria-label='Move page up'
|
||||
>
|
||||
<BaseIcon path={mdiChevronUp} size={22} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => onMovePage?.('down')}
|
||||
disabled={!canMovePageDown}
|
||||
className='flex h-9 w-9 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35'
|
||||
title='Move page down'
|
||||
aria-label='Move page down'
|
||||
>
|
||||
<BaseIcon path={mdiChevronDown} size={22} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onDuplicatePage}
|
||||
disabled={!canDuplicatePage}
|
||||
className='flex h-9 w-9 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35'
|
||||
title='Duplicate page'
|
||||
aria-label='Duplicate page'
|
||||
>
|
||||
<BaseIcon path={mdiContentDuplicate} size={20} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onDeletePage}
|
||||
disabled={!canDeleteCurrentPage}
|
||||
className='flex h-9 w-9 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35'
|
||||
title='Delete page'
|
||||
aria-label='Delete page'
|
||||
>
|
||||
<BaseIcon path={mdiDelete} size={20} />
|
||||
</button>
|
||||
<div className='flex h-[58px] items-start border-r border-white/15 pt-[15px] pr-3'>
|
||||
<InteractionModeToggle
|
||||
mode={interactionMode}
|
||||
onModeChange={onModeChange}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mode Toggle - reuse with compact=true */}
|
||||
<InteractionModeToggle
|
||||
mode={interactionMode}
|
||||
onModeChange={onModeChange}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<div className='w-px h-8 bg-white/30' />
|
||||
|
||||
{/* Backgrounds Dropdown */}
|
||||
<div className='relative'>
|
||||
<button
|
||||
ref={bgTriggerRef}
|
||||
type='button'
|
||||
onClick={() => toggleDropdown('bg')}
|
||||
className={triggerBtnClass}
|
||||
>
|
||||
<BaseIcon path={mdiImageMultiple} size={18} />
|
||||
<span>BG</span>
|
||||
<BaseIcon path={mdiChevronDown} size={16} />
|
||||
</button>
|
||||
{activeDropdown === 'bg' && (
|
||||
<ClickOutside
|
||||
onClickOutside={closeDropdown}
|
||||
excludedElements={[bgTriggerRef]}
|
||||
<div className={sectionClass}>
|
||||
<span className={sectionLabelClass}>
|
||||
Page actions
|
||||
</span>
|
||||
<div className='flex h-10 items-center gap-2'>
|
||||
<PageSelector
|
||||
pages={pages}
|
||||
activePageId={activePageId}
|
||||
onPageChange={onPageChange}
|
||||
disabled={isReorderingPages}
|
||||
className='h-10 min-w-[210px]'
|
||||
/>
|
||||
<div className='flex items-center gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => onMovePage?.('up')}
|
||||
disabled={!canMovePageUp}
|
||||
className={iconBtnClass}
|
||||
title='Move page up'
|
||||
aria-label='Move page up'
|
||||
>
|
||||
<BaseIcon path={mdiChevronUp} size={22} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => onMovePage?.('down')}
|
||||
disabled={!canMovePageDown}
|
||||
className={iconBtnClass}
|
||||
title='Move page down'
|
||||
aria-label='Move page down'
|
||||
>
|
||||
<BaseIcon path={mdiChevronDown} size={22} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onCreatePage}
|
||||
disabled={isCreatingPage}
|
||||
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<div className={dropdownPanelClass}>
|
||||
<MenuActionButton
|
||||
icon={mdiImageMultiple}
|
||||
label='Background Image'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onSelectMenuItem('background_image'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiVideo}
|
||||
label='Background Video'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onSelectMenuItem('background_video'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiPanoramaHorizontal}
|
||||
label='Background 360'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onSelectMenuItem('background_embed'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiMusicNote}
|
||||
label='Background Audio'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onSelectMenuItem('background_audio'))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ClickOutside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Elements Dropdown */}
|
||||
<div className='relative'>
|
||||
<button
|
||||
ref={elementsTriggerRef}
|
||||
type='button'
|
||||
onClick={() => toggleDropdown('elements')}
|
||||
className={triggerBtnClass}
|
||||
>
|
||||
<BaseIcon path={mdiPlus} size={18} />
|
||||
<span>Elements</span>
|
||||
<BaseIcon path={mdiChevronDown} size={16} />
|
||||
</button>
|
||||
{activeDropdown === 'elements' && (
|
||||
<ClickOutside
|
||||
onClickOutside={closeDropdown}
|
||||
excludedElements={[elementsTriggerRef]}
|
||||
<BaseIcon path={mdiPlus} size={18} />
|
||||
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onDuplicatePage}
|
||||
disabled={!canDuplicatePage}
|
||||
className={iconBtnClass}
|
||||
title='Duplicate page'
|
||||
aria-label='Duplicate page'
|
||||
>
|
||||
<div className={dropdownPanelClass}>
|
||||
<MenuActionButton
|
||||
icon={mdiSwapHorizontal}
|
||||
label='Navigation Button'
|
||||
onClick={() =>
|
||||
handleMenuAction(() =>
|
||||
onAddElement(allowedNavigationTypes[0]),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiImageMultiple}
|
||||
label='Gallery'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('gallery'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiViewCarousel}
|
||||
label='Carousel'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('carousel'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiText}
|
||||
label='Description'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('description'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiVideo}
|
||||
label='Video Player'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('video_player'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiMusicNote}
|
||||
label='Audio Player'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('audio_player'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiInformationOutline}
|
||||
label='Info Panel'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('info_panel'))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ClickOutside>
|
||||
)}
|
||||
<BaseIcon path={mdiContentDuplicate} size={20} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onDeletePage}
|
||||
disabled={!canDeleteCurrentPage}
|
||||
className='flex h-10 w-10 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35'
|
||||
title='Delete page'
|
||||
aria-label='Delete page'
|
||||
>
|
||||
<BaseIcon path={mdiDelete} size={20} />
|
||||
</button>
|
||||
<div className='relative'>
|
||||
<button
|
||||
ref={bgTriggerRef}
|
||||
type='button'
|
||||
onClick={() => toggleDropdown('bg')}
|
||||
className={triggerBtnClass}
|
||||
>
|
||||
<BaseIcon path={mdiImageMultiple} size={18} />
|
||||
<span>BG</span>
|
||||
<BaseIcon path={mdiChevronDown} size={16} />
|
||||
</button>
|
||||
{activeDropdown === 'bg' && (
|
||||
<ClickOutside
|
||||
onClickOutside={closeDropdown}
|
||||
excludedElements={[bgTriggerRef]}
|
||||
>
|
||||
<div className={dropdownPanelClass}>
|
||||
<MenuActionButton
|
||||
icon={mdiImageMultiple}
|
||||
label='Background Image'
|
||||
onClick={() =>
|
||||
handleMenuAction(() =>
|
||||
onSelectMenuItem('background_image'),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiVideo}
|
||||
label='Background Video'
|
||||
onClick={() =>
|
||||
handleMenuAction(() =>
|
||||
onSelectMenuItem('background_video'),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiPanoramaHorizontal}
|
||||
label='Background 360'
|
||||
onClick={() =>
|
||||
handleMenuAction(() =>
|
||||
onSelectMenuItem('background_embed'),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiMusicNote}
|
||||
label='Background Audio'
|
||||
onClick={() =>
|
||||
handleMenuAction(() =>
|
||||
onSelectMenuItem('background_audio'),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ClickOutside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className='w-px h-8 bg-white/30' />
|
||||
<div className={sectionClass}>
|
||||
<span className={sectionLabelClass}>
|
||||
Elements actions
|
||||
</span>
|
||||
<div className='flex h-10 items-center gap-2'>
|
||||
<div className='relative'>
|
||||
<button
|
||||
ref={elementsTriggerRef}
|
||||
type='button'
|
||||
onClick={() => toggleDropdown('elements')}
|
||||
className={triggerBtnClass}
|
||||
>
|
||||
<BaseIcon path={mdiPlus} size={18} />
|
||||
<span>Elements</span>
|
||||
<BaseIcon path={mdiChevronDown} size={16} />
|
||||
</button>
|
||||
{activeDropdown === 'elements' && (
|
||||
<ClickOutside
|
||||
onClickOutside={closeDropdown}
|
||||
excludedElements={[elementsTriggerRef]}
|
||||
>
|
||||
<div className={dropdownPanelClass}>
|
||||
<MenuActionButton
|
||||
icon={mdiSwapHorizontal}
|
||||
label='Navigation Button'
|
||||
onClick={() =>
|
||||
handleMenuAction(() =>
|
||||
onAddElement(allowedNavigationTypes[0]),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiImageMultiple}
|
||||
label='Gallery'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('gallery'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiViewCarousel}
|
||||
label='Carousel'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('carousel'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiText}
|
||||
label='Description'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('description'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiVideo}
|
||||
label='Video Player'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('video_player'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiMusicNote}
|
||||
label='Audio Player'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('audio_player'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiInformationOutline}
|
||||
label='Info Panel'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onAddElement('info_panel'))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ClickOutside>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onCopyElement}
|
||||
disabled={!canCopyCurrentElement}
|
||||
className={iconBtnClass}
|
||||
title='Copy selected element'
|
||||
aria-label='Copy selected element'
|
||||
>
|
||||
<BaseIcon path={mdiContentDuplicate} size={20} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onPasteElement}
|
||||
disabled={!canPasteCurrentElement}
|
||||
className={iconBtnClass}
|
||||
title='Paste copied element'
|
||||
aria-label='Paste copied element'
|
||||
>
|
||||
<BaseIcon path={mdiContentPaste} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Page Button */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={onCreatePage}
|
||||
disabled={isCreatingPage}
|
||||
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<BaseIcon path={mdiPlus} size={18} />
|
||||
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
|
||||
</button>
|
||||
<div className='flex h-[58px] items-start gap-2 border-l border-white/15 pt-4 pl-3'>
|
||||
{/* Save Button - reuse BaseButton with subtitle */}
|
||||
<BaseButton
|
||||
small
|
||||
color='info'
|
||||
label={isSaving ? 'Saving...' : 'Save'}
|
||||
subtitle={
|
||||
lastSavedAt
|
||||
? dataFormatter.relativeTimestamp(lastSavedAt)
|
||||
: undefined
|
||||
}
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{/* Save Button - reuse BaseButton with subtitle */}
|
||||
<BaseButton
|
||||
small
|
||||
color='info'
|
||||
label={isSaving ? 'Saving...' : 'Save'}
|
||||
subtitle={
|
||||
lastSavedAt
|
||||
? dataFormatter.relativeTimestamp(lastSavedAt)
|
||||
: undefined
|
||||
}
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
{/* Save to Stage Button */}
|
||||
<BaseButton
|
||||
small
|
||||
color='success'
|
||||
label={isSavingToStage ? 'Saving...' : 'Stage'}
|
||||
subtitle={
|
||||
lastSavedToStageAt
|
||||
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
|
||||
: undefined
|
||||
}
|
||||
onClick={onSaveToStage}
|
||||
disabled={isSavingToStage}
|
||||
/>
|
||||
|
||||
{/* Save to Stage Button */}
|
||||
<BaseButton
|
||||
small
|
||||
color='success'
|
||||
label={isSavingToStage ? 'Saving...' : 'Stage'}
|
||||
subtitle={
|
||||
lastSavedToStageAt
|
||||
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
|
||||
: undefined
|
||||
}
|
||||
onClick={onSaveToStage}
|
||||
disabled={isSavingToStage}
|
||||
/>
|
||||
{/* Exit Button */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={onExit}
|
||||
className='flex h-10 w-10 items-center justify-center rounded text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300'
|
||||
title='Exit constructor'
|
||||
>
|
||||
<BaseIcon path={mdiExitToApp} size={26} />
|
||||
</button>
|
||||
|
||||
{/* Exit Button */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={onExit}
|
||||
className='flex items-center justify-center w-10 h-10 rounded text-red-400 hover:text-red-300 hover:bg-red-500/20 transition-colors'
|
||||
title='Exit constructor'
|
||||
>
|
||||
<BaseIcon path={mdiExitToApp} size={26} />
|
||||
</button>
|
||||
|
||||
{/* Collapse Toggle */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className='flex items-center justify-center w-10 h-10 rounded text-white/60 hover:text-white/90 hover:bg-white/20 transition-colors'
|
||||
title='Collapse toolbar'
|
||||
>
|
||||
<BaseIcon path={mdiChevronLeft} size={26} />
|
||||
</button>
|
||||
{/* Collapse Toggle */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className='flex h-10 w-10 items-center justify-center rounded text-white/60 transition-colors hover:bg-white/20 hover:text-white/90'
|
||||
title='Collapse toolbar'
|
||||
>
|
||||
<BaseIcon path={mdiChevronLeft} size={26} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@ -36,6 +36,7 @@ const ElementEditorHeader: React.FC<ElementEditorHeaderProps> = ({
|
||||
<button
|
||||
type='button'
|
||||
className='text-[10px] text-white/70 hover:text-white hover:underline'
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onToggleCollapse}
|
||||
>
|
||||
{isCollapsed ? 'Expand' : 'Collapse'}
|
||||
@ -44,6 +45,7 @@ const ElementEditorHeader: React.FC<ElementEditorHeaderProps> = ({
|
||||
<button
|
||||
type='button'
|
||||
className='text-[10px] text-red-400 hover:text-red-300 hover:underline'
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onRemove}
|
||||
>
|
||||
Remove
|
||||
|
||||
@ -19,13 +19,17 @@ const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({
|
||||
compact = false,
|
||||
}) => {
|
||||
const isEditMode = mode === 'edit';
|
||||
const wrapperClass = compact
|
||||
? 'flex items-center'
|
||||
: 'flex flex-wrap items-center gap-2';
|
||||
const buttonClass = compact ? 'h-10 px-4 py-0' : 'px-3 py-1.5';
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<div className={wrapperClass}>
|
||||
<div className='inline-flex overflow-hidden rounded border border-white/30 bg-white/10 text-xs font-semibold'>
|
||||
<button
|
||||
type='button'
|
||||
className={`px-3 py-1.5 transition-colors ${
|
||||
className={`${buttonClass} transition-colors ${
|
||||
isEditMode
|
||||
? 'bg-blue-500/80 text-white'
|
||||
: 'text-white/70 hover:bg-white/10'
|
||||
@ -36,7 +40,7 @@ const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className={`border-l border-white/30 px-3 py-1.5 transition-colors ${
|
||||
className={`border-l border-white/30 ${buttonClass} transition-colors ${
|
||||
!isEditMode
|
||||
? 'bg-blue-500/80 text-white'
|
||||
: 'text-white/70 hover:bg-white/10'
|
||||
|
||||
@ -13,6 +13,7 @@ interface PageSelectorProps {
|
||||
activePageId: string;
|
||||
onPageChange: (pageId: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PageSelector: React.FC<PageSelectorProps> = ({
|
||||
@ -20,6 +21,7 @@ const PageSelector: React.FC<PageSelectorProps> = ({
|
||||
activePageId,
|
||||
onPageChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
// Sort pages by sort_order ascending, then by name
|
||||
const sortedPages = useMemo(() => {
|
||||
@ -45,7 +47,7 @@ const PageSelector: React.FC<PageSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<select
|
||||
className='rounded border border-white/30 bg-white/20 pl-3 pr-8 py-1.5 text-sm text-white/90 backdrop-blur-sm focus:outline-none focus:ring-1 focus:ring-white/40 appearance-none bg-no-repeat bg-[length:16px] bg-[right_8px_center]'
|
||||
className={`rounded border border-white/30 bg-white/20 pl-3 pr-8 py-1.5 text-sm text-white/90 backdrop-blur-sm focus:outline-none focus:ring-1 focus:ring-white/40 appearance-none bg-no-repeat bg-[length:16px] bg-[right_8px_center] ${className}`}
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='rgba(255,255,255,0.7)' d='M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'/%3E%3C/svg%3E")`,
|
||||
}}
|
||||
|
||||
@ -269,6 +269,10 @@ export interface ConstructorToolbarProps {
|
||||
// Element actions
|
||||
allowedNavigationTypes: NavigationElementType[];
|
||||
onAddElement: (type: CanvasElementType) => void;
|
||||
onCopyElement?: () => void;
|
||||
onPasteElement?: () => void;
|
||||
canCopyElement?: boolean;
|
||||
canPasteElement?: boolean;
|
||||
|
||||
// Page actions
|
||||
onCreatePage: () => void;
|
||||
|
||||
@ -132,14 +132,19 @@ export interface ConstructorContextValue {
|
||||
// Element state
|
||||
elements: CanvasElement[];
|
||||
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
|
||||
getElements: () => CanvasElement[];
|
||||
selectedElementId: string | null;
|
||||
selectedElement: CanvasElement | null;
|
||||
copiedElement: CanvasElement | null;
|
||||
canPasteElement: boolean;
|
||||
selectElement: (id: string) => void;
|
||||
clearSelection: () => void;
|
||||
updateElement: (id: string, patch: Partial<CanvasElement>) => void;
|
||||
removeElement: (id: string) => void;
|
||||
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
|
||||
removeSelectedElement: () => void;
|
||||
copySelectedElement: () => void;
|
||||
pasteCopiedElement: () => CanvasElement | null;
|
||||
|
||||
// Menu state
|
||||
selectedMenuItem: EditorMenuItem;
|
||||
@ -281,26 +286,36 @@ export function useConstructorElements() {
|
||||
() => ({
|
||||
elements: ctx.elements,
|
||||
setElements: ctx.setElements,
|
||||
getElements: ctx.getElements,
|
||||
selectedElementId: ctx.selectedElementId,
|
||||
selectedElement: ctx.selectedElement,
|
||||
copiedElement: ctx.copiedElement,
|
||||
canPasteElement: ctx.canPasteElement,
|
||||
selectElement: ctx.selectElement,
|
||||
clearSelection: ctx.clearSelection,
|
||||
updateElement: ctx.updateElement,
|
||||
removeElement: ctx.removeElement,
|
||||
updateSelectedElement: ctx.updateSelectedElement,
|
||||
removeSelectedElement: ctx.removeSelectedElement,
|
||||
copySelectedElement: ctx.copySelectedElement,
|
||||
pasteCopiedElement: ctx.pasteCopiedElement,
|
||||
}),
|
||||
[
|
||||
ctx.elements,
|
||||
ctx.setElements,
|
||||
ctx.getElements,
|
||||
ctx.selectedElementId,
|
||||
ctx.selectedElement,
|
||||
ctx.copiedElement,
|
||||
ctx.canPasteElement,
|
||||
ctx.selectElement,
|
||||
ctx.clearSelection,
|
||||
ctx.updateElement,
|
||||
ctx.removeElement,
|
||||
ctx.updateSelectedElement,
|
||||
ctx.removeSelectedElement,
|
||||
ctx.copySelectedElement,
|
||||
ctx.pasteCopiedElement,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
* Used in constructor.tsx for adding, updating, and removing UI elements.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import type {
|
||||
CanvasElement,
|
||||
CanvasElementType,
|
||||
@ -25,6 +25,7 @@ import {
|
||||
import {
|
||||
createDefaultElement,
|
||||
createLocalId,
|
||||
cloneElementForPaste,
|
||||
mergeElementWithDefaults,
|
||||
isGalleryElementType,
|
||||
isCarouselElementType,
|
||||
@ -63,6 +64,10 @@ interface UseConstructorElementsOptions {
|
||||
onElementAdded?: (element: CanvasElement) => void;
|
||||
/** Callback when an element is removed */
|
||||
onElementRemoved?: (elementId: string) => void;
|
||||
/** Callback when an element is copied */
|
||||
onElementCopied?: (element: CanvasElement) => void;
|
||||
/** Callback when an element is pasted */
|
||||
onElementPasted?: (element: CanvasElement) => void;
|
||||
}
|
||||
|
||||
interface UseConstructorElementsResult {
|
||||
@ -70,10 +75,16 @@ interface UseConstructorElementsResult {
|
||||
elements: CanvasElement[];
|
||||
/** Set elements directly */
|
||||
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
|
||||
/** Read the latest elements synchronously, including just-applied updates */
|
||||
getElements: () => CanvasElement[];
|
||||
/** Currently selected element ID */
|
||||
selectedElementId: string;
|
||||
/** Currently selected element (or null) */
|
||||
selectedElement: CanvasElement | null;
|
||||
/** Element copied inside the current constructor session */
|
||||
copiedElement: CanvasElement | null;
|
||||
/** Whether an element can be pasted into the current page */
|
||||
canPasteElement: boolean;
|
||||
/** Select an element for editing */
|
||||
selectElement: (elementId: string) => void;
|
||||
/** Clear selection */
|
||||
@ -90,6 +101,10 @@ interface UseConstructorElementsResult {
|
||||
updateElement: (elementId: string, patch: Partial<CanvasElement>) => void;
|
||||
/** Remove the selected element */
|
||||
removeSelectedElement: () => void;
|
||||
/** Copy the selected element into the constructor clipboard */
|
||||
copySelectedElement: () => void;
|
||||
/** Paste the copied element into the current page */
|
||||
pasteCopiedElement: () => CanvasElement | null;
|
||||
/** Remove an element by ID */
|
||||
removeElement: (elementId: string) => void;
|
||||
/** Gallery card operations */
|
||||
@ -212,8 +227,32 @@ export function useConstructorElements({
|
||||
onSelectionCleared,
|
||||
onElementAdded,
|
||||
onElementRemoved,
|
||||
onElementCopied,
|
||||
onElementPasted,
|
||||
}: UseConstructorElementsOptions = {}): UseConstructorElementsResult {
|
||||
const [elements, setElements] = useState<CanvasElement[]>(initialElements);
|
||||
const elementsRef = useRef<CanvasElement[]>(initialElements);
|
||||
const [copiedElement, setCopiedElement] = useState<CanvasElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const setElementsWithRef = useCallback(
|
||||
(value: React.SetStateAction<CanvasElement[]>) => {
|
||||
const nextElements =
|
||||
typeof value === 'function'
|
||||
? (value as (prev: CanvasElement[]) => CanvasElement[])(
|
||||
elementsRef.current,
|
||||
)
|
||||
: value;
|
||||
|
||||
elementsRef.current = nextElements;
|
||||
setElements(nextElements);
|
||||
onElementsChange?.(nextElements);
|
||||
},
|
||||
[onElementsChange],
|
||||
);
|
||||
|
||||
const getElements = useCallback(() => elementsRef.current, []);
|
||||
// Initialize selectedElementId from route param if element exists in initialElements
|
||||
const [selectedElementId, setSelectedElementId] = useState(() => {
|
||||
if (
|
||||
@ -229,6 +268,7 @@ export function useConstructorElements({
|
||||
() => elements.find((el) => el.id === selectedElementId) || null,
|
||||
[elements, selectedElementId],
|
||||
);
|
||||
const canPasteElement = Boolean(copiedElement);
|
||||
|
||||
const selectElement = useCallback(
|
||||
(elementId: string) => {
|
||||
@ -255,9 +295,8 @@ export function useConstructorElements({
|
||||
const defaults = elementDefaultsByType[effectiveType];
|
||||
const newElement = mergeElementWithDefaults(baseElement, defaults);
|
||||
|
||||
setElements((prev) => {
|
||||
setElementsWithRef((prev) => {
|
||||
const next = [...prev, newElement];
|
||||
onElementsChange?.(next);
|
||||
return next;
|
||||
});
|
||||
setSelectedElementId(newElement.id);
|
||||
@ -268,7 +307,7 @@ export function useConstructorElements({
|
||||
allowedNavigationTypes,
|
||||
elements.length,
|
||||
elementDefaultsByType,
|
||||
onElementsChange,
|
||||
setElementsWithRef,
|
||||
onElementSelected,
|
||||
onElementAdded,
|
||||
],
|
||||
@ -282,31 +321,29 @@ export function useConstructorElements({
|
||||
) => {
|
||||
if (!selectedElementId) return;
|
||||
|
||||
setElements((prev) => {
|
||||
setElementsWithRef((prev) => {
|
||||
const next = prev.map((el) => {
|
||||
if (el.id !== selectedElementId) return el;
|
||||
const patch =
|
||||
typeof patchOrFn === 'function' ? patchOrFn(el) : patchOrFn;
|
||||
return { ...el, ...patch };
|
||||
});
|
||||
onElementsChange?.(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[selectedElementId, onElementsChange],
|
||||
[selectedElementId, setElementsWithRef],
|
||||
);
|
||||
|
||||
const updateElement = useCallback(
|
||||
(elementId: string, patch: Partial<CanvasElement>) => {
|
||||
setElements((prev) => {
|
||||
setElementsWithRef((prev) => {
|
||||
const next = prev.map((el) =>
|
||||
el.id === elementId ? { ...el, ...patch } : el,
|
||||
);
|
||||
onElementsChange?.(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[onElementsChange],
|
||||
[setElementsWithRef],
|
||||
);
|
||||
|
||||
const removeSelectedElement = useCallback(() => {
|
||||
@ -314,10 +351,9 @@ export function useConstructorElements({
|
||||
const removedId = selectedElementId;
|
||||
|
||||
let nextSelectedId = '';
|
||||
setElements((prev) => {
|
||||
setElementsWithRef((prev) => {
|
||||
const filtered = prev.filter((el) => el.id !== selectedElementId);
|
||||
nextSelectedId = filtered[0]?.id || '';
|
||||
onElementsChange?.(filtered);
|
||||
return filtered;
|
||||
});
|
||||
|
||||
@ -331,7 +367,7 @@ export function useConstructorElements({
|
||||
onElementRemoved?.(removedId);
|
||||
}, [
|
||||
selectedElementId,
|
||||
onElementsChange,
|
||||
setElementsWithRef,
|
||||
onElementSelected,
|
||||
onSelectionCleared,
|
||||
onElementRemoved,
|
||||
@ -339,9 +375,8 @@ export function useConstructorElements({
|
||||
|
||||
const removeElement = useCallback(
|
||||
(elementId: string) => {
|
||||
setElements((prev) => {
|
||||
setElementsWithRef((prev) => {
|
||||
const next = prev.filter((el) => el.id !== elementId);
|
||||
onElementsChange?.(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
@ -349,18 +384,46 @@ export function useConstructorElements({
|
||||
setSelectedElementId('');
|
||||
}
|
||||
},
|
||||
[selectedElementId, onElementsChange],
|
||||
[selectedElementId, setElementsWithRef],
|
||||
);
|
||||
|
||||
const copySelectedElement = useCallback(() => {
|
||||
if (!selectedElement) return;
|
||||
|
||||
const clonedForClipboard = cloneElementForPaste(selectedElement);
|
||||
setCopiedElement(clonedForClipboard);
|
||||
onElementCopied?.(selectedElement);
|
||||
}, [selectedElement, onElementCopied]);
|
||||
|
||||
const pasteCopiedElement = useCallback(() => {
|
||||
if (!copiedElement) return null;
|
||||
|
||||
const pastedElement = cloneElementForPaste(copiedElement);
|
||||
setElementsWithRef((prev) => {
|
||||
const next = [...prev, pastedElement];
|
||||
return next;
|
||||
});
|
||||
setSelectedElementId(pastedElement.id);
|
||||
onElementSelected?.(pastedElement.id);
|
||||
onElementPasted?.(pastedElement);
|
||||
|
||||
return pastedElement;
|
||||
}, [
|
||||
copiedElement,
|
||||
onElementSelected,
|
||||
onElementPasted,
|
||||
setElementsWithRef,
|
||||
]);
|
||||
|
||||
const updateElementPosition = useCallback(
|
||||
(elementId: string, xPercent: number, yPercent: number) => {
|
||||
setElements((prev) =>
|
||||
setElementsWithRef((prev) =>
|
||||
prev.map((el) =>
|
||||
el.id === elementId ? { ...el, xPercent, yPercent } : el,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
[setElementsWithRef],
|
||||
);
|
||||
|
||||
// Gallery card operations
|
||||
@ -678,15 +741,20 @@ export function useConstructorElements({
|
||||
|
||||
return {
|
||||
elements,
|
||||
setElements,
|
||||
setElements: setElementsWithRef,
|
||||
getElements,
|
||||
selectedElementId,
|
||||
selectedElement,
|
||||
copiedElement,
|
||||
canPasteElement,
|
||||
selectElement,
|
||||
clearSelection,
|
||||
addElement,
|
||||
updateSelectedElement,
|
||||
updateElement,
|
||||
removeSelectedElement,
|
||||
copySelectedElement,
|
||||
pasteCopiedElement,
|
||||
removeElement,
|
||||
galleryCards,
|
||||
galleryInfoSpans,
|
||||
|
||||
@ -60,6 +60,8 @@ interface UseConstructorPageActionsOptions {
|
||||
activePageId: string;
|
||||
/** Current elements array */
|
||||
elements: CanvasElement[];
|
||||
/** Latest elements getter for same-tick paste/save flows */
|
||||
getElements?: () => CanvasElement[];
|
||||
/** Consolidated page background state */
|
||||
pageBackground: PageBackgroundState;
|
||||
/** Callback to reload data after operations */
|
||||
@ -126,6 +128,7 @@ export function useConstructorPageActions({
|
||||
activePage,
|
||||
activePageId,
|
||||
elements,
|
||||
getElements,
|
||||
pageBackground,
|
||||
onReload,
|
||||
onSetActivePageId,
|
||||
@ -194,10 +197,11 @@ export function useConstructorPageActions({
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const elementsToSave = getElements ? getElements() : elements;
|
||||
|
||||
// Capture pending reverse video keys BEFORE save
|
||||
// These are elements that will trigger async reversed video generation
|
||||
const pendingReverseKeys = getPendingReverseVideoKeys(elements);
|
||||
const pendingReverseKeys = getPendingReverseVideoKeys(elementsToSave);
|
||||
|
||||
const existingSchema = parseJsonObject<Record<string, any>>(
|
||||
activePage?.ui_schema_json,
|
||||
@ -205,7 +209,7 @@ export function useConstructorPageActions({
|
||||
);
|
||||
const schemaToSave = {
|
||||
...existingSchema,
|
||||
elements,
|
||||
elements: elementsToSave,
|
||||
};
|
||||
|
||||
await axios.put(`/tour_pages/${activePageId}`, {
|
||||
@ -279,6 +283,7 @@ export function useConstructorPageActions({
|
||||
activePageId,
|
||||
pageBackground,
|
||||
elements,
|
||||
getElements,
|
||||
project?.design_width,
|
||||
project?.design_height,
|
||||
onError,
|
||||
|
||||
@ -67,6 +67,68 @@ export const createLocalId = (): string => {
|
||||
return `element_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
};
|
||||
|
||||
const deepCloneElement = (element: CanvasElement): CanvasElement => {
|
||||
if (typeof structuredClone === 'function') {
|
||||
return structuredClone(element);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(element)) as CanvasElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deep-copy an existing element as a new independent canvas instance.
|
||||
*
|
||||
* All settings, media, navigation targets, transitions, styles, effects, and
|
||||
* positions are preserved. Only element-instance IDs are regenerated.
|
||||
*/
|
||||
export const cloneElementForPaste = (element: CanvasElement): CanvasElement => {
|
||||
const cloned = deepCloneElement(element);
|
||||
|
||||
cloned.id = createLocalId();
|
||||
|
||||
if (Array.isArray(cloned.galleryCards)) {
|
||||
cloned.galleryCards = cloned.galleryCards.map((card) => ({
|
||||
...card,
|
||||
id: createLocalId(),
|
||||
}));
|
||||
}
|
||||
|
||||
if (Array.isArray(cloned.galleryInfoSpans)) {
|
||||
cloned.galleryInfoSpans = cloned.galleryInfoSpans.map((span) => ({
|
||||
...span,
|
||||
id: createLocalId(),
|
||||
}));
|
||||
}
|
||||
|
||||
if (Array.isArray(cloned.carouselSlides)) {
|
||||
cloned.carouselSlides = cloned.carouselSlides.map((slide) => ({
|
||||
...slide,
|
||||
id: createLocalId(),
|
||||
}));
|
||||
}
|
||||
|
||||
if (Array.isArray(cloned.infoPanelSections)) {
|
||||
cloned.infoPanelSections = cloned.infoPanelSections.map((section) => ({
|
||||
...section,
|
||||
id: createLocalId(),
|
||||
spans: Array.isArray(section.spans)
|
||||
? section.spans.map((span) => ({
|
||||
...span,
|
||||
id: createLocalId(),
|
||||
}))
|
||||
: section.spans,
|
||||
images: Array.isArray(section.images)
|
||||
? section.images.map((image) => ({
|
||||
...image,
|
||||
id: createLocalId(),
|
||||
}))
|
||||
: section.images,
|
||||
}));
|
||||
}
|
||||
|
||||
return cloned;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clamp a number between min and max
|
||||
*/
|
||||
|
||||
@ -136,6 +136,19 @@ const sortTourPagesForDisplay = (items: TourPage[]) =>
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
|
||||
const isEditableShortcutTarget = (target: EventTarget | null) => {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
return (
|
||||
tagName === 'input' ||
|
||||
tagName === 'textarea' ||
|
||||
tagName === 'select' ||
|
||||
target.isContentEditable ||
|
||||
Boolean(target.closest('[contenteditable="true"]'))
|
||||
);
|
||||
};
|
||||
|
||||
// Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup
|
||||
const labelByType = ELEMENT_TYPE_LABELS;
|
||||
|
||||
@ -335,13 +348,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const {
|
||||
elements,
|
||||
setElements,
|
||||
getElements,
|
||||
selectedElementId,
|
||||
selectedElement,
|
||||
copiedElement,
|
||||
canPasteElement,
|
||||
selectElement,
|
||||
clearSelection,
|
||||
addElement,
|
||||
updateSelectedElement,
|
||||
removeSelectedElement,
|
||||
copySelectedElement,
|
||||
pasteCopiedElement,
|
||||
galleryCards,
|
||||
galleryInfoSpans,
|
||||
carouselSlides,
|
||||
@ -366,6 +384,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
onElementRemoved: useCallback(() => {
|
||||
setSuccessMessage('Element removed.');
|
||||
}, []),
|
||||
onElementCopied: useCallback(() => {
|
||||
setSuccessMessage('Element copied.');
|
||||
setErrorMessage('');
|
||||
}, []),
|
||||
onElementPasted: useCallback(() => {
|
||||
setSuccessMessage('Element pasted.');
|
||||
setErrorMessage('');
|
||||
}, []),
|
||||
});
|
||||
|
||||
// Check if any element has full-width carousel mode enabled
|
||||
@ -1022,6 +1048,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
activePage,
|
||||
activePageId,
|
||||
elements,
|
||||
getElements,
|
||||
pageBackground,
|
||||
onReload: handleReload,
|
||||
onSetActivePageId: setActivePageId,
|
||||
@ -1545,6 +1572,42 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
[clearSelection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConstructorEditMode || isLoading || !activePageId) return;
|
||||
|
||||
const handleElementClipboardShortcut = (event: KeyboardEvent) => {
|
||||
if (!event.metaKey && !event.ctrlKey) return;
|
||||
if (event.altKey || event.shiftKey) return;
|
||||
if (isEditableShortcutTarget(event.target)) return;
|
||||
|
||||
const shortcutKey = event.key.toLowerCase();
|
||||
if (shortcutKey === 'c') {
|
||||
if (!selectedElement) return;
|
||||
event.preventDefault();
|
||||
copySelectedElement();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shortcutKey === 'v') {
|
||||
if (!canPasteElement) return;
|
||||
event.preventDefault();
|
||||
pasteCopiedElement();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleElementClipboardShortcut);
|
||||
return () =>
|
||||
window.removeEventListener('keydown', handleElementClipboardShortcut);
|
||||
}, [
|
||||
activePageId,
|
||||
canPasteElement,
|
||||
copySelectedElement,
|
||||
isConstructorEditMode,
|
||||
isLoading,
|
||||
pasteCopiedElement,
|
||||
selectedElement,
|
||||
]);
|
||||
|
||||
// createPage, saveConstructor, saveToStage are now provided by useConstructorPageActions hook
|
||||
|
||||
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
|
||||
@ -1973,8 +2036,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
// Element state
|
||||
elements,
|
||||
setElements,
|
||||
getElements,
|
||||
selectedElementId,
|
||||
selectedElement,
|
||||
copiedElement,
|
||||
canPasteElement,
|
||||
selectElement,
|
||||
clearSelection,
|
||||
updateElement: (id: string, patch: Partial<CanvasElement>) => {
|
||||
@ -1990,6 +2056,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
},
|
||||
updateSelectedElement,
|
||||
removeSelectedElement,
|
||||
copySelectedElement,
|
||||
pasteCopiedElement,
|
||||
|
||||
// Menu state
|
||||
selectedMenuItem,
|
||||
@ -2048,12 +2116,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
setBackgroundVideoSettings,
|
||||
elements,
|
||||
setElements,
|
||||
getElements,
|
||||
selectedElementId,
|
||||
selectedElement,
|
||||
copiedElement,
|
||||
canPasteElement,
|
||||
selectElement,
|
||||
clearSelection,
|
||||
updateSelectedElement,
|
||||
removeSelectedElement,
|
||||
copySelectedElement,
|
||||
pasteCopiedElement,
|
||||
selectedMenuItem,
|
||||
isMenuOpen,
|
||||
elementEditorTab,
|
||||
@ -2139,6 +2212,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
onSelectMenuItem={selectMenuItemForEdit}
|
||||
allowedNavigationTypes={allowedNavigationTypes}
|
||||
onAddElement={addElement}
|
||||
onCopyElement={copySelectedElement}
|
||||
onPasteElement={pasteCopiedElement}
|
||||
canCopyElement={Boolean(selectedElement)}
|
||||
canPasteElement={canPasteElement}
|
||||
onCreatePage={handleShowCreatePageModal}
|
||||
isCreatingPage={isCreatingPage}
|
||||
onSave={saveConstructor}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user