Added elements cop/pase functionality

This commit is contained in:
Dmitri 2026-06-27 13:24:20 +02:00
parent cd588bac8b
commit 490dd98e52
10 changed files with 551 additions and 264 deletions

View File

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

View File

@ -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

View File

@ -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'

View File

@ -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")`,
}} }}

View File

@ -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;

View File

@ -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,
], ],
); );
} }

View File

@ -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,

View File

@ -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,

View File

@ -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
*/ */

View File

@ -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}