Added elements cop/pase functionality
This commit is contained in:
parent
cd588bac8b
commit
490dd98e52
@ -20,6 +20,7 @@ import {
|
|||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
mdiChevronUp,
|
mdiChevronUp,
|
||||||
mdiContentDuplicate,
|
mdiContentDuplicate,
|
||||||
|
mdiContentPaste,
|
||||||
mdiMusicNote,
|
mdiMusicNote,
|
||||||
mdiVideo,
|
mdiVideo,
|
||||||
mdiInformationOutline,
|
mdiInformationOutline,
|
||||||
@ -54,6 +55,10 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
onSelectMenuItem,
|
onSelectMenuItem,
|
||||||
allowedNavigationTypes,
|
allowedNavigationTypes,
|
||||||
onAddElement,
|
onAddElement,
|
||||||
|
onCopyElement,
|
||||||
|
onPasteElement,
|
||||||
|
canCopyElement = false,
|
||||||
|
canPasteElement = false,
|
||||||
onCreatePage,
|
onCreatePage,
|
||||||
isCreatingPage,
|
isCreatingPage,
|
||||||
onSave,
|
onSave,
|
||||||
@ -111,6 +116,8 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
!isDeletingPage &&
|
!isDeletingPage &&
|
||||||
!isReorderingPages &&
|
!isReorderingPages &&
|
||||||
activePageIndex >= 0;
|
activePageIndex >= 0;
|
||||||
|
const canCopyCurrentElement = Boolean(onCopyElement) && canCopyElement;
|
||||||
|
const canPasteCurrentElement = Boolean(onPasteElement) && canPasteElement;
|
||||||
|
|
||||||
// Keyboard handling (Escape closes dropdown)
|
// Keyboard handling (Escape closes dropdown)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -137,7 +144,13 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
|
|
||||||
// Shared button styles
|
// Shared button styles
|
||||||
const triggerBtnClass =
|
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 =
|
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';
|
'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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
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 }}
|
style={{ left: position.x, top: position.y }}
|
||||||
>
|
>
|
||||||
{/* Drag Handle */}
|
{/* Drag Handle */}
|
||||||
@ -184,253 +197,288 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
<BaseIcon path={mdiDotsVertical} size={24} />
|
<BaseIcon path={mdiDotsVertical} size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page Selector - reuse existing component */}
|
<div className='flex h-[58px] items-start border-r border-white/15 pt-[15px] pr-3'>
|
||||||
<PageSelector
|
<InteractionModeToggle
|
||||||
pages={pages}
|
mode={interactionMode}
|
||||||
activePageId={activePageId}
|
onModeChange={onModeChange}
|
||||||
onPageChange={onPageChange}
|
compact
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Mode Toggle - reuse with compact=true */}
|
<div className={sectionClass}>
|
||||||
<InteractionModeToggle
|
<span className={sectionLabelClass}>
|
||||||
mode={interactionMode}
|
Page actions
|
||||||
onModeChange={onModeChange}
|
</span>
|
||||||
compact
|
<div className='flex h-10 items-center gap-2'>
|
||||||
/>
|
<PageSelector
|
||||||
|
pages={pages}
|
||||||
{/* Divider */}
|
activePageId={activePageId}
|
||||||
<div className='w-px h-8 bg-white/30' />
|
onPageChange={onPageChange}
|
||||||
|
disabled={isReorderingPages}
|
||||||
{/* Backgrounds Dropdown */}
|
className='h-10 min-w-[210px]'
|
||||||
<div className='relative'>
|
/>
|
||||||
<button
|
<div className='flex items-center gap-1'>
|
||||||
ref={bgTriggerRef}
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => toggleDropdown('bg')}
|
onClick={() => onMovePage?.('up')}
|
||||||
className={triggerBtnClass}
|
disabled={!canMovePageUp}
|
||||||
>
|
className={iconBtnClass}
|
||||||
<BaseIcon path={mdiImageMultiple} size={18} />
|
title='Move page up'
|
||||||
<span>BG</span>
|
aria-label='Move page up'
|
||||||
<BaseIcon path={mdiChevronDown} size={16} />
|
>
|
||||||
</button>
|
<BaseIcon path={mdiChevronUp} size={22} />
|
||||||
{activeDropdown === 'bg' && (
|
</button>
|
||||||
<ClickOutside
|
<button
|
||||||
onClickOutside={closeDropdown}
|
type='button'
|
||||||
excludedElements={[bgTriggerRef]}
|
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}>
|
<BaseIcon path={mdiPlus} size={18} />
|
||||||
<MenuActionButton
|
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
|
||||||
icon={mdiImageMultiple}
|
</button>
|
||||||
label='Background Image'
|
<button
|
||||||
onClick={() =>
|
type='button'
|
||||||
handleMenuAction(() => onSelectMenuItem('background_image'))
|
onClick={onDuplicatePage}
|
||||||
}
|
disabled={!canDuplicatePage}
|
||||||
/>
|
className={iconBtnClass}
|
||||||
<MenuActionButton
|
title='Duplicate page'
|
||||||
icon={mdiVideo}
|
aria-label='Duplicate page'
|
||||||
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]}
|
|
||||||
>
|
>
|
||||||
<div className={dropdownPanelClass}>
|
<BaseIcon path={mdiContentDuplicate} size={20} />
|
||||||
<MenuActionButton
|
</button>
|
||||||
icon={mdiSwapHorizontal}
|
<button
|
||||||
label='Navigation Button'
|
type='button'
|
||||||
onClick={() =>
|
onClick={onDeletePage}
|
||||||
handleMenuAction(() =>
|
disabled={!canDeleteCurrentPage}
|
||||||
onAddElement(allowedNavigationTypes[0]),
|
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'
|
||||||
/>
|
>
|
||||||
<MenuActionButton
|
<BaseIcon path={mdiDelete} size={20} />
|
||||||
icon={mdiImageMultiple}
|
</button>
|
||||||
label='Gallery'
|
<div className='relative'>
|
||||||
onClick={() =>
|
<button
|
||||||
handleMenuAction(() => onAddElement('gallery'))
|
ref={bgTriggerRef}
|
||||||
}
|
type='button'
|
||||||
/>
|
onClick={() => toggleDropdown('bg')}
|
||||||
<MenuActionButton
|
className={triggerBtnClass}
|
||||||
icon={mdiViewCarousel}
|
>
|
||||||
label='Carousel'
|
<BaseIcon path={mdiImageMultiple} size={18} />
|
||||||
onClick={() =>
|
<span>BG</span>
|
||||||
handleMenuAction(() => onAddElement('carousel'))
|
<BaseIcon path={mdiChevronDown} size={16} />
|
||||||
}
|
</button>
|
||||||
/>
|
{activeDropdown === 'bg' && (
|
||||||
<MenuActionButton
|
<ClickOutside
|
||||||
icon={mdiText}
|
onClickOutside={closeDropdown}
|
||||||
label='Description'
|
excludedElements={[bgTriggerRef]}
|
||||||
onClick={() =>
|
>
|
||||||
handleMenuAction(() => onAddElement('description'))
|
<div className={dropdownPanelClass}>
|
||||||
}
|
<MenuActionButton
|
||||||
/>
|
icon={mdiImageMultiple}
|
||||||
<MenuActionButton
|
label='Background Image'
|
||||||
icon={mdiVideo}
|
onClick={() =>
|
||||||
label='Video Player'
|
handleMenuAction(() =>
|
||||||
onClick={() =>
|
onSelectMenuItem('background_image'),
|
||||||
handleMenuAction(() => onAddElement('video_player'))
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MenuActionButton
|
<MenuActionButton
|
||||||
icon={mdiMusicNote}
|
icon={mdiVideo}
|
||||||
label='Audio Player'
|
label='Background Video'
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleMenuAction(() => onAddElement('audio_player'))
|
handleMenuAction(() =>
|
||||||
}
|
onSelectMenuItem('background_video'),
|
||||||
/>
|
)
|
||||||
<MenuActionButton
|
}
|
||||||
icon={mdiInformationOutline}
|
/>
|
||||||
label='Info Panel'
|
<MenuActionButton
|
||||||
onClick={() =>
|
icon={mdiPanoramaHorizontal}
|
||||||
handleMenuAction(() => onAddElement('info_panel'))
|
label='Background 360'
|
||||||
}
|
onClick={() =>
|
||||||
/>
|
handleMenuAction(() =>
|
||||||
</div>
|
onSelectMenuItem('background_embed'),
|
||||||
</ClickOutside>
|
)
|
||||||
)}
|
}
|
||||||
|
/>
|
||||||
|
<MenuActionButton
|
||||||
|
icon={mdiMusicNote}
|
||||||
|
label='Background Audio'
|
||||||
|
onClick={() =>
|
||||||
|
handleMenuAction(() =>
|
||||||
|
onSelectMenuItem('background_audio'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ClickOutside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
<div className={sectionClass}>
|
||||||
<div className='w-px h-8 bg-white/30' />
|
<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 */}
|
<div className='flex h-[58px] items-start gap-2 border-l border-white/15 pt-4 pl-3'>
|
||||||
<button
|
{/* Save Button - reuse BaseButton with subtitle */}
|
||||||
type='button'
|
<BaseButton
|
||||||
onClick={onCreatePage}
|
small
|
||||||
disabled={isCreatingPage}
|
color='info'
|
||||||
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
|
label={isSaving ? 'Saving...' : 'Save'}
|
||||||
>
|
subtitle={
|
||||||
<BaseIcon path={mdiPlus} size={18} />
|
lastSavedAt
|
||||||
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
|
? dataFormatter.relativeTimestamp(lastSavedAt)
|
||||||
</button>
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Save Button - reuse BaseButton with subtitle */}
|
{/* Save to Stage Button */}
|
||||||
<BaseButton
|
<BaseButton
|
||||||
small
|
small
|
||||||
color='info'
|
color='success'
|
||||||
label={isSaving ? 'Saving...' : 'Save'}
|
label={isSavingToStage ? 'Saving...' : 'Stage'}
|
||||||
subtitle={
|
subtitle={
|
||||||
lastSavedAt
|
lastSavedToStageAt
|
||||||
? dataFormatter.relativeTimestamp(lastSavedAt)
|
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClick={onSave}
|
onClick={onSaveToStage}
|
||||||
disabled={isSaving}
|
disabled={isSavingToStage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Save to Stage Button */}
|
{/* Exit Button */}
|
||||||
<BaseButton
|
<button
|
||||||
small
|
type='button'
|
||||||
color='success'
|
onClick={onExit}
|
||||||
label={isSavingToStage ? 'Saving...' : 'Stage'}
|
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'
|
||||||
subtitle={
|
title='Exit constructor'
|
||||||
lastSavedToStageAt
|
>
|
||||||
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
|
<BaseIcon path={mdiExitToApp} size={26} />
|
||||||
: undefined
|
</button>
|
||||||
}
|
|
||||||
onClick={onSaveToStage}
|
|
||||||
disabled={isSavingToStage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Exit Button */}
|
{/* Collapse Toggle */}
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={onExit}
|
onClick={() => setIsCollapsed(true)}
|
||||||
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'
|
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='Exit constructor'
|
title='Collapse toolbar'
|
||||||
>
|
>
|
||||||
<BaseIcon path={mdiExitToApp} size={26} />
|
<BaseIcon path={mdiChevronLeft} size={26} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{/* 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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -36,6 +36,7 @@ const ElementEditorHeader: React.FC<ElementEditorHeaderProps> = ({
|
|||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
className='text-[10px] text-white/70 hover:text-white hover:underline'
|
className='text-[10px] text-white/70 hover:text-white hover:underline'
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
>
|
>
|
||||||
{isCollapsed ? 'Expand' : 'Collapse'}
|
{isCollapsed ? 'Expand' : 'Collapse'}
|
||||||
@ -44,6 +45,7 @@ const ElementEditorHeader: React.FC<ElementEditorHeaderProps> = ({
|
|||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
className='text-[10px] text-red-400 hover:text-red-300 hover:underline'
|
className='text-[10px] text-red-400 hover:text-red-300 hover:underline'
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
|
|||||||
@ -19,13 +19,17 @@ const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({
|
|||||||
compact = false,
|
compact = false,
|
||||||
}) => {
|
}) => {
|
||||||
const isEditMode = mode === 'edit';
|
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 (
|
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'>
|
<div className='inline-flex overflow-hidden rounded border border-white/30 bg-white/10 text-xs font-semibold'>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
className={`px-3 py-1.5 transition-colors ${
|
className={`${buttonClass} transition-colors ${
|
||||||
isEditMode
|
isEditMode
|
||||||
? 'bg-blue-500/80 text-white'
|
? 'bg-blue-500/80 text-white'
|
||||||
: 'text-white/70 hover:bg-white/10'
|
: 'text-white/70 hover:bg-white/10'
|
||||||
@ -36,7 +40,7 @@ const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type='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
|
!isEditMode
|
||||||
? 'bg-blue-500/80 text-white'
|
? 'bg-blue-500/80 text-white'
|
||||||
: 'text-white/70 hover:bg-white/10'
|
: 'text-white/70 hover:bg-white/10'
|
||||||
|
|||||||
@ -13,6 +13,7 @@ interface PageSelectorProps {
|
|||||||
activePageId: string;
|
activePageId: string;
|
||||||
onPageChange: (pageId: string) => void;
|
onPageChange: (pageId: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageSelector: React.FC<PageSelectorProps> = ({
|
const PageSelector: React.FC<PageSelectorProps> = ({
|
||||||
@ -20,6 +21,7 @@ const PageSelector: React.FC<PageSelectorProps> = ({
|
|||||||
activePageId,
|
activePageId,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
// Sort pages by sort_order ascending, then by name
|
// Sort pages by sort_order ascending, then by name
|
||||||
const sortedPages = useMemo(() => {
|
const sortedPages = useMemo(() => {
|
||||||
@ -45,7 +47,7 @@ const PageSelector: React.FC<PageSelectorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<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={{
|
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")`,
|
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
|
// Element actions
|
||||||
allowedNavigationTypes: NavigationElementType[];
|
allowedNavigationTypes: NavigationElementType[];
|
||||||
onAddElement: (type: CanvasElementType) => void;
|
onAddElement: (type: CanvasElementType) => void;
|
||||||
|
onCopyElement?: () => void;
|
||||||
|
onPasteElement?: () => void;
|
||||||
|
canCopyElement?: boolean;
|
||||||
|
canPasteElement?: boolean;
|
||||||
|
|
||||||
// Page actions
|
// Page actions
|
||||||
onCreatePage: () => void;
|
onCreatePage: () => void;
|
||||||
|
|||||||
@ -132,14 +132,19 @@ export interface ConstructorContextValue {
|
|||||||
// Element state
|
// Element state
|
||||||
elements: CanvasElement[];
|
elements: CanvasElement[];
|
||||||
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
|
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
|
||||||
|
getElements: () => CanvasElement[];
|
||||||
selectedElementId: string | null;
|
selectedElementId: string | null;
|
||||||
selectedElement: CanvasElement | null;
|
selectedElement: CanvasElement | null;
|
||||||
|
copiedElement: CanvasElement | null;
|
||||||
|
canPasteElement: boolean;
|
||||||
selectElement: (id: string) => void;
|
selectElement: (id: string) => void;
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
updateElement: (id: string, patch: Partial<CanvasElement>) => void;
|
updateElement: (id: string, patch: Partial<CanvasElement>) => void;
|
||||||
removeElement: (id: string) => void;
|
removeElement: (id: string) => void;
|
||||||
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
|
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
|
||||||
removeSelectedElement: () => void;
|
removeSelectedElement: () => void;
|
||||||
|
copySelectedElement: () => void;
|
||||||
|
pasteCopiedElement: () => CanvasElement | null;
|
||||||
|
|
||||||
// Menu state
|
// Menu state
|
||||||
selectedMenuItem: EditorMenuItem;
|
selectedMenuItem: EditorMenuItem;
|
||||||
@ -281,26 +286,36 @@ export function useConstructorElements() {
|
|||||||
() => ({
|
() => ({
|
||||||
elements: ctx.elements,
|
elements: ctx.elements,
|
||||||
setElements: ctx.setElements,
|
setElements: ctx.setElements,
|
||||||
|
getElements: ctx.getElements,
|
||||||
selectedElementId: ctx.selectedElementId,
|
selectedElementId: ctx.selectedElementId,
|
||||||
selectedElement: ctx.selectedElement,
|
selectedElement: ctx.selectedElement,
|
||||||
|
copiedElement: ctx.copiedElement,
|
||||||
|
canPasteElement: ctx.canPasteElement,
|
||||||
selectElement: ctx.selectElement,
|
selectElement: ctx.selectElement,
|
||||||
clearSelection: ctx.clearSelection,
|
clearSelection: ctx.clearSelection,
|
||||||
updateElement: ctx.updateElement,
|
updateElement: ctx.updateElement,
|
||||||
removeElement: ctx.removeElement,
|
removeElement: ctx.removeElement,
|
||||||
updateSelectedElement: ctx.updateSelectedElement,
|
updateSelectedElement: ctx.updateSelectedElement,
|
||||||
removeSelectedElement: ctx.removeSelectedElement,
|
removeSelectedElement: ctx.removeSelectedElement,
|
||||||
|
copySelectedElement: ctx.copySelectedElement,
|
||||||
|
pasteCopiedElement: ctx.pasteCopiedElement,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
ctx.elements,
|
ctx.elements,
|
||||||
ctx.setElements,
|
ctx.setElements,
|
||||||
|
ctx.getElements,
|
||||||
ctx.selectedElementId,
|
ctx.selectedElementId,
|
||||||
ctx.selectedElement,
|
ctx.selectedElement,
|
||||||
|
ctx.copiedElement,
|
||||||
|
ctx.canPasteElement,
|
||||||
ctx.selectElement,
|
ctx.selectElement,
|
||||||
ctx.clearSelection,
|
ctx.clearSelection,
|
||||||
ctx.updateElement,
|
ctx.updateElement,
|
||||||
ctx.removeElement,
|
ctx.removeElement,
|
||||||
ctx.updateSelectedElement,
|
ctx.updateSelectedElement,
|
||||||
ctx.removeSelectedElement,
|
ctx.removeSelectedElement,
|
||||||
|
ctx.copySelectedElement,
|
||||||
|
ctx.pasteCopiedElement,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
* Used in constructor.tsx for adding, updating, and removing UI elements.
|
* 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 {
|
import type {
|
||||||
CanvasElement,
|
CanvasElement,
|
||||||
CanvasElementType,
|
CanvasElementType,
|
||||||
@ -25,6 +25,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createDefaultElement,
|
createDefaultElement,
|
||||||
createLocalId,
|
createLocalId,
|
||||||
|
cloneElementForPaste,
|
||||||
mergeElementWithDefaults,
|
mergeElementWithDefaults,
|
||||||
isGalleryElementType,
|
isGalleryElementType,
|
||||||
isCarouselElementType,
|
isCarouselElementType,
|
||||||
@ -63,6 +64,10 @@ interface UseConstructorElementsOptions {
|
|||||||
onElementAdded?: (element: CanvasElement) => void;
|
onElementAdded?: (element: CanvasElement) => void;
|
||||||
/** Callback when an element is removed */
|
/** Callback when an element is removed */
|
||||||
onElementRemoved?: (elementId: string) => void;
|
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 {
|
interface UseConstructorElementsResult {
|
||||||
@ -70,10 +75,16 @@ interface UseConstructorElementsResult {
|
|||||||
elements: CanvasElement[];
|
elements: CanvasElement[];
|
||||||
/** Set elements directly */
|
/** Set elements directly */
|
||||||
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
|
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
|
||||||
|
/** Read the latest elements synchronously, including just-applied updates */
|
||||||
|
getElements: () => CanvasElement[];
|
||||||
/** Currently selected element ID */
|
/** Currently selected element ID */
|
||||||
selectedElementId: string;
|
selectedElementId: string;
|
||||||
/** Currently selected element (or null) */
|
/** Currently selected element (or null) */
|
||||||
selectedElement: CanvasElement | 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 */
|
/** Select an element for editing */
|
||||||
selectElement: (elementId: string) => void;
|
selectElement: (elementId: string) => void;
|
||||||
/** Clear selection */
|
/** Clear selection */
|
||||||
@ -90,6 +101,10 @@ interface UseConstructorElementsResult {
|
|||||||
updateElement: (elementId: string, patch: Partial<CanvasElement>) => void;
|
updateElement: (elementId: string, patch: Partial<CanvasElement>) => void;
|
||||||
/** Remove the selected element */
|
/** Remove the selected element */
|
||||||
removeSelectedElement: () => void;
|
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 */
|
/** Remove an element by ID */
|
||||||
removeElement: (elementId: string) => void;
|
removeElement: (elementId: string) => void;
|
||||||
/** Gallery card operations */
|
/** Gallery card operations */
|
||||||
@ -212,8 +227,32 @@ export function useConstructorElements({
|
|||||||
onSelectionCleared,
|
onSelectionCleared,
|
||||||
onElementAdded,
|
onElementAdded,
|
||||||
onElementRemoved,
|
onElementRemoved,
|
||||||
|
onElementCopied,
|
||||||
|
onElementPasted,
|
||||||
}: UseConstructorElementsOptions = {}): UseConstructorElementsResult {
|
}: UseConstructorElementsOptions = {}): UseConstructorElementsResult {
|
||||||
const [elements, setElements] = useState<CanvasElement[]>(initialElements);
|
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
|
// Initialize selectedElementId from route param if element exists in initialElements
|
||||||
const [selectedElementId, setSelectedElementId] = useState(() => {
|
const [selectedElementId, setSelectedElementId] = useState(() => {
|
||||||
if (
|
if (
|
||||||
@ -229,6 +268,7 @@ export function useConstructorElements({
|
|||||||
() => elements.find((el) => el.id === selectedElementId) || null,
|
() => elements.find((el) => el.id === selectedElementId) || null,
|
||||||
[elements, selectedElementId],
|
[elements, selectedElementId],
|
||||||
);
|
);
|
||||||
|
const canPasteElement = Boolean(copiedElement);
|
||||||
|
|
||||||
const selectElement = useCallback(
|
const selectElement = useCallback(
|
||||||
(elementId: string) => {
|
(elementId: string) => {
|
||||||
@ -255,9 +295,8 @@ export function useConstructorElements({
|
|||||||
const defaults = elementDefaultsByType[effectiveType];
|
const defaults = elementDefaultsByType[effectiveType];
|
||||||
const newElement = mergeElementWithDefaults(baseElement, defaults);
|
const newElement = mergeElementWithDefaults(baseElement, defaults);
|
||||||
|
|
||||||
setElements((prev) => {
|
setElementsWithRef((prev) => {
|
||||||
const next = [...prev, newElement];
|
const next = [...prev, newElement];
|
||||||
onElementsChange?.(next);
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setSelectedElementId(newElement.id);
|
setSelectedElementId(newElement.id);
|
||||||
@ -268,7 +307,7 @@ export function useConstructorElements({
|
|||||||
allowedNavigationTypes,
|
allowedNavigationTypes,
|
||||||
elements.length,
|
elements.length,
|
||||||
elementDefaultsByType,
|
elementDefaultsByType,
|
||||||
onElementsChange,
|
setElementsWithRef,
|
||||||
onElementSelected,
|
onElementSelected,
|
||||||
onElementAdded,
|
onElementAdded,
|
||||||
],
|
],
|
||||||
@ -282,31 +321,29 @@ export function useConstructorElements({
|
|||||||
) => {
|
) => {
|
||||||
if (!selectedElementId) return;
|
if (!selectedElementId) return;
|
||||||
|
|
||||||
setElements((prev) => {
|
setElementsWithRef((prev) => {
|
||||||
const next = prev.map((el) => {
|
const next = prev.map((el) => {
|
||||||
if (el.id !== selectedElementId) return el;
|
if (el.id !== selectedElementId) return el;
|
||||||
const patch =
|
const patch =
|
||||||
typeof patchOrFn === 'function' ? patchOrFn(el) : patchOrFn;
|
typeof patchOrFn === 'function' ? patchOrFn(el) : patchOrFn;
|
||||||
return { ...el, ...patch };
|
return { ...el, ...patch };
|
||||||
});
|
});
|
||||||
onElementsChange?.(next);
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[selectedElementId, onElementsChange],
|
[selectedElementId, setElementsWithRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateElement = useCallback(
|
const updateElement = useCallback(
|
||||||
(elementId: string, patch: Partial<CanvasElement>) => {
|
(elementId: string, patch: Partial<CanvasElement>) => {
|
||||||
setElements((prev) => {
|
setElementsWithRef((prev) => {
|
||||||
const next = prev.map((el) =>
|
const next = prev.map((el) =>
|
||||||
el.id === elementId ? { ...el, ...patch } : el,
|
el.id === elementId ? { ...el, ...patch } : el,
|
||||||
);
|
);
|
||||||
onElementsChange?.(next);
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[onElementsChange],
|
[setElementsWithRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeSelectedElement = useCallback(() => {
|
const removeSelectedElement = useCallback(() => {
|
||||||
@ -314,10 +351,9 @@ export function useConstructorElements({
|
|||||||
const removedId = selectedElementId;
|
const removedId = selectedElementId;
|
||||||
|
|
||||||
let nextSelectedId = '';
|
let nextSelectedId = '';
|
||||||
setElements((prev) => {
|
setElementsWithRef((prev) => {
|
||||||
const filtered = prev.filter((el) => el.id !== selectedElementId);
|
const filtered = prev.filter((el) => el.id !== selectedElementId);
|
||||||
nextSelectedId = filtered[0]?.id || '';
|
nextSelectedId = filtered[0]?.id || '';
|
||||||
onElementsChange?.(filtered);
|
|
||||||
return filtered;
|
return filtered;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -331,7 +367,7 @@ export function useConstructorElements({
|
|||||||
onElementRemoved?.(removedId);
|
onElementRemoved?.(removedId);
|
||||||
}, [
|
}, [
|
||||||
selectedElementId,
|
selectedElementId,
|
||||||
onElementsChange,
|
setElementsWithRef,
|
||||||
onElementSelected,
|
onElementSelected,
|
||||||
onSelectionCleared,
|
onSelectionCleared,
|
||||||
onElementRemoved,
|
onElementRemoved,
|
||||||
@ -339,9 +375,8 @@ export function useConstructorElements({
|
|||||||
|
|
||||||
const removeElement = useCallback(
|
const removeElement = useCallback(
|
||||||
(elementId: string) => {
|
(elementId: string) => {
|
||||||
setElements((prev) => {
|
setElementsWithRef((prev) => {
|
||||||
const next = prev.filter((el) => el.id !== elementId);
|
const next = prev.filter((el) => el.id !== elementId);
|
||||||
onElementsChange?.(next);
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -349,18 +384,46 @@ export function useConstructorElements({
|
|||||||
setSelectedElementId('');
|
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(
|
const updateElementPosition = useCallback(
|
||||||
(elementId: string, xPercent: number, yPercent: number) => {
|
(elementId: string, xPercent: number, yPercent: number) => {
|
||||||
setElements((prev) =>
|
setElementsWithRef((prev) =>
|
||||||
prev.map((el) =>
|
prev.map((el) =>
|
||||||
el.id === elementId ? { ...el, xPercent, yPercent } : el,
|
el.id === elementId ? { ...el, xPercent, yPercent } : el,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[],
|
[setElementsWithRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Gallery card operations
|
// Gallery card operations
|
||||||
@ -678,15 +741,20 @@ export function useConstructorElements({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
setElements,
|
setElements: setElementsWithRef,
|
||||||
|
getElements,
|
||||||
selectedElementId,
|
selectedElementId,
|
||||||
selectedElement,
|
selectedElement,
|
||||||
|
copiedElement,
|
||||||
|
canPasteElement,
|
||||||
selectElement,
|
selectElement,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
addElement,
|
addElement,
|
||||||
updateSelectedElement,
|
updateSelectedElement,
|
||||||
updateElement,
|
updateElement,
|
||||||
removeSelectedElement,
|
removeSelectedElement,
|
||||||
|
copySelectedElement,
|
||||||
|
pasteCopiedElement,
|
||||||
removeElement,
|
removeElement,
|
||||||
galleryCards,
|
galleryCards,
|
||||||
galleryInfoSpans,
|
galleryInfoSpans,
|
||||||
|
|||||||
@ -60,6 +60,8 @@ interface UseConstructorPageActionsOptions {
|
|||||||
activePageId: string;
|
activePageId: string;
|
||||||
/** Current elements array */
|
/** Current elements array */
|
||||||
elements: CanvasElement[];
|
elements: CanvasElement[];
|
||||||
|
/** Latest elements getter for same-tick paste/save flows */
|
||||||
|
getElements?: () => CanvasElement[];
|
||||||
/** Consolidated page background state */
|
/** Consolidated page background state */
|
||||||
pageBackground: PageBackgroundState;
|
pageBackground: PageBackgroundState;
|
||||||
/** Callback to reload data after operations */
|
/** Callback to reload data after operations */
|
||||||
@ -126,6 +128,7 @@ export function useConstructorPageActions({
|
|||||||
activePage,
|
activePage,
|
||||||
activePageId,
|
activePageId,
|
||||||
elements,
|
elements,
|
||||||
|
getElements,
|
||||||
pageBackground,
|
pageBackground,
|
||||||
onReload,
|
onReload,
|
||||||
onSetActivePageId,
|
onSetActivePageId,
|
||||||
@ -194,10 +197,11 @@ export function useConstructorPageActions({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
const elementsToSave = getElements ? getElements() : elements;
|
||||||
|
|
||||||
// Capture pending reverse video keys BEFORE save
|
// Capture pending reverse video keys BEFORE save
|
||||||
// These are elements that will trigger async reversed video generation
|
// These are elements that will trigger async reversed video generation
|
||||||
const pendingReverseKeys = getPendingReverseVideoKeys(elements);
|
const pendingReverseKeys = getPendingReverseVideoKeys(elementsToSave);
|
||||||
|
|
||||||
const existingSchema = parseJsonObject<Record<string, any>>(
|
const existingSchema = parseJsonObject<Record<string, any>>(
|
||||||
activePage?.ui_schema_json,
|
activePage?.ui_schema_json,
|
||||||
@ -205,7 +209,7 @@ export function useConstructorPageActions({
|
|||||||
);
|
);
|
||||||
const schemaToSave = {
|
const schemaToSave = {
|
||||||
...existingSchema,
|
...existingSchema,
|
||||||
elements,
|
elements: elementsToSave,
|
||||||
};
|
};
|
||||||
|
|
||||||
await axios.put(`/tour_pages/${activePageId}`, {
|
await axios.put(`/tour_pages/${activePageId}`, {
|
||||||
@ -279,6 +283,7 @@ export function useConstructorPageActions({
|
|||||||
activePageId,
|
activePageId,
|
||||||
pageBackground,
|
pageBackground,
|
||||||
elements,
|
elements,
|
||||||
|
getElements,
|
||||||
project?.design_width,
|
project?.design_width,
|
||||||
project?.design_height,
|
project?.design_height,
|
||||||
onError,
|
onError,
|
||||||
|
|||||||
@ -67,6 +67,68 @@ export const createLocalId = (): string => {
|
|||||||
return `element_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
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
|
* Clamp a number between min and max
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -136,6 +136,19 @@ const sortTourPagesForDisplay = (items: TourPage[]) =>
|
|||||||
return (a.name || '').localeCompare(b.name || '');
|
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
|
// Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup
|
||||||
const labelByType = ELEMENT_TYPE_LABELS;
|
const labelByType = ELEMENT_TYPE_LABELS;
|
||||||
|
|
||||||
@ -335,13 +348,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
const {
|
const {
|
||||||
elements,
|
elements,
|
||||||
setElements,
|
setElements,
|
||||||
|
getElements,
|
||||||
selectedElementId,
|
selectedElementId,
|
||||||
selectedElement,
|
selectedElement,
|
||||||
|
copiedElement,
|
||||||
|
canPasteElement,
|
||||||
selectElement,
|
selectElement,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
addElement,
|
addElement,
|
||||||
updateSelectedElement,
|
updateSelectedElement,
|
||||||
removeSelectedElement,
|
removeSelectedElement,
|
||||||
|
copySelectedElement,
|
||||||
|
pasteCopiedElement,
|
||||||
galleryCards,
|
galleryCards,
|
||||||
galleryInfoSpans,
|
galleryInfoSpans,
|
||||||
carouselSlides,
|
carouselSlides,
|
||||||
@ -366,6 +384,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
onElementRemoved: useCallback(() => {
|
onElementRemoved: useCallback(() => {
|
||||||
setSuccessMessage('Element removed.');
|
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
|
// Check if any element has full-width carousel mode enabled
|
||||||
@ -1022,6 +1048,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
activePage,
|
activePage,
|
||||||
activePageId,
|
activePageId,
|
||||||
elements,
|
elements,
|
||||||
|
getElements,
|
||||||
pageBackground,
|
pageBackground,
|
||||||
onReload: handleReload,
|
onReload: handleReload,
|
||||||
onSetActivePageId: setActivePageId,
|
onSetActivePageId: setActivePageId,
|
||||||
@ -1545,6 +1572,42 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
[clearSelection],
|
[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
|
// createPage, saveConstructor, saveToStage are now provided by useConstructorPageActions hook
|
||||||
|
|
||||||
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
|
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
|
||||||
@ -1973,8 +2036,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
// Element state
|
// Element state
|
||||||
elements,
|
elements,
|
||||||
setElements,
|
setElements,
|
||||||
|
getElements,
|
||||||
selectedElementId,
|
selectedElementId,
|
||||||
selectedElement,
|
selectedElement,
|
||||||
|
copiedElement,
|
||||||
|
canPasteElement,
|
||||||
selectElement,
|
selectElement,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
updateElement: (id: string, patch: Partial<CanvasElement>) => {
|
updateElement: (id: string, patch: Partial<CanvasElement>) => {
|
||||||
@ -1990,6 +2056,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
},
|
},
|
||||||
updateSelectedElement,
|
updateSelectedElement,
|
||||||
removeSelectedElement,
|
removeSelectedElement,
|
||||||
|
copySelectedElement,
|
||||||
|
pasteCopiedElement,
|
||||||
|
|
||||||
// Menu state
|
// Menu state
|
||||||
selectedMenuItem,
|
selectedMenuItem,
|
||||||
@ -2048,12 +2116,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
setBackgroundVideoSettings,
|
setBackgroundVideoSettings,
|
||||||
elements,
|
elements,
|
||||||
setElements,
|
setElements,
|
||||||
|
getElements,
|
||||||
selectedElementId,
|
selectedElementId,
|
||||||
selectedElement,
|
selectedElement,
|
||||||
|
copiedElement,
|
||||||
|
canPasteElement,
|
||||||
selectElement,
|
selectElement,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
updateSelectedElement,
|
updateSelectedElement,
|
||||||
removeSelectedElement,
|
removeSelectedElement,
|
||||||
|
copySelectedElement,
|
||||||
|
pasteCopiedElement,
|
||||||
selectedMenuItem,
|
selectedMenuItem,
|
||||||
isMenuOpen,
|
isMenuOpen,
|
||||||
elementEditorTab,
|
elementEditorTab,
|
||||||
@ -2139,6 +2212,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
onSelectMenuItem={selectMenuItemForEdit}
|
onSelectMenuItem={selectMenuItemForEdit}
|
||||||
allowedNavigationTypes={allowedNavigationTypes}
|
allowedNavigationTypes={allowedNavigationTypes}
|
||||||
onAddElement={addElement}
|
onAddElement={addElement}
|
||||||
|
onCopyElement={copySelectedElement}
|
||||||
|
onPasteElement={pasteCopiedElement}
|
||||||
|
canCopyElement={Boolean(selectedElement)}
|
||||||
|
canPasteElement={canPasteElement}
|
||||||
onCreatePage={handleShowCreatePageModal}
|
onCreatePage={handleShowCreatePageModal}
|
||||||
isCreatingPage={isCreatingPage}
|
isCreatingPage={isCreatingPage}
|
||||||
onSave={saveConstructor}
|
onSave={saveConstructor}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user