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,
mdiChevronUp,
mdiContentDuplicate,
mdiContentPaste,
mdiMusicNote,
mdiVideo,
mdiInformationOutline,
@ -54,6 +55,10 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
onSelectMenuItem,
allowedNavigationTypes,
onAddElement,
onCopyElement,
onPasteElement,
canCopyElement = false,
canPasteElement = false,
onCreatePage,
isCreatingPage,
onSave,
@ -111,6 +116,8 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
!isDeletingPage &&
!isReorderingPages &&
activePageIndex >= 0;
const canCopyCurrentElement = Boolean(onCopyElement) && canCopyElement;
const canPasteCurrentElement = Boolean(onPasteElement) && canPasteElement;
// Keyboard handling (Escape closes dropdown)
useEffect(() => {
@ -137,7 +144,13 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
// Shared button styles
const triggerBtnClass =
'flex items-center gap-1.5 px-3 py-2 rounded text-sm font-medium text-white/90 hover:bg-white/20 transition-colors';
'flex h-10 items-center gap-1.5 rounded px-3 text-sm font-medium text-white/90 transition-colors hover:bg-white/20';
const iconBtnClass =
'flex h-10 w-10 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35';
const sectionClass =
'flex h-[58px] flex-col justify-center gap-1 border-x border-white/15 px-3';
const sectionLabelClass =
'text-center text-[9px] font-semibold uppercase leading-none tracking-wide text-white/50';
const dropdownPanelClass =
'absolute top-full left-0 mt-1 min-w-[180px] py-1 rounded-lg bg-white/50 backdrop-blur-xl border border-white/30 shadow-lg z-10 flex flex-col items-start';
@ -173,7 +186,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
return (
<div
ref={ref}
className='fixed z-[1000] flex items-center gap-2 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl max-w-[95vw]'
className='fixed z-[1000] flex min-h-[72px] items-center gap-2 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl max-w-[95vw]'
style={{ left: position.x, top: position.y }}
>
{/* Drag Handle */}
@ -184,253 +197,288 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
<BaseIcon path={mdiDotsVertical} size={24} />
</div>
{/* Page Selector - reuse existing component */}
<PageSelector
pages={pages}
activePageId={activePageId}
onPageChange={onPageChange}
disabled={isReorderingPages}
/>
<div className='flex items-center gap-1'>
<button
type='button'
onClick={() => onMovePage?.('up')}
disabled={!canMovePageUp}
className='flex h-9 w-9 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35'
title='Move page up'
aria-label='Move page up'
>
<BaseIcon path={mdiChevronUp} size={22} />
</button>
<button
type='button'
onClick={() => onMovePage?.('down')}
disabled={!canMovePageDown}
className='flex h-9 w-9 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35'
title='Move page down'
aria-label='Move page down'
>
<BaseIcon path={mdiChevronDown} size={22} />
</button>
<button
type='button'
onClick={onDuplicatePage}
disabled={!canDuplicatePage}
className='flex h-9 w-9 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35'
title='Duplicate page'
aria-label='Duplicate page'
>
<BaseIcon path={mdiContentDuplicate} size={20} />
</button>
<button
type='button'
onClick={onDeletePage}
disabled={!canDeleteCurrentPage}
className='flex h-9 w-9 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35'
title='Delete page'
aria-label='Delete page'
>
<BaseIcon path={mdiDelete} size={20} />
</button>
<div className='flex h-[58px] items-start border-r border-white/15 pt-[15px] pr-3'>
<InteractionModeToggle
mode={interactionMode}
onModeChange={onModeChange}
compact
/>
</div>
{/* Mode Toggle - reuse with compact=true */}
<InteractionModeToggle
mode={interactionMode}
onModeChange={onModeChange}
compact
/>
{/* Divider */}
<div className='w-px h-8 bg-white/30' />
{/* Backgrounds Dropdown */}
<div className='relative'>
<button
ref={bgTriggerRef}
type='button'
onClick={() => toggleDropdown('bg')}
className={triggerBtnClass}
>
<BaseIcon path={mdiImageMultiple} size={18} />
<span>BG</span>
<BaseIcon path={mdiChevronDown} size={16} />
</button>
{activeDropdown === 'bg' && (
<ClickOutside
onClickOutside={closeDropdown}
excludedElements={[bgTriggerRef]}
<div className={sectionClass}>
<span className={sectionLabelClass}>
Page actions
</span>
<div className='flex h-10 items-center gap-2'>
<PageSelector
pages={pages}
activePageId={activePageId}
onPageChange={onPageChange}
disabled={isReorderingPages}
className='h-10 min-w-[210px]'
/>
<div className='flex items-center gap-1'>
<button
type='button'
onClick={() => onMovePage?.('up')}
disabled={!canMovePageUp}
className={iconBtnClass}
title='Move page up'
aria-label='Move page up'
>
<BaseIcon path={mdiChevronUp} size={22} />
</button>
<button
type='button'
onClick={() => onMovePage?.('down')}
disabled={!canMovePageDown}
className={iconBtnClass}
title='Move page down'
aria-label='Move page down'
>
<BaseIcon path={mdiChevronDown} size={22} />
</button>
</div>
<button
type='button'
onClick={onCreatePage}
disabled={isCreatingPage}
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className={dropdownPanelClass}>
<MenuActionButton
icon={mdiImageMultiple}
label='Background Image'
onClick={() =>
handleMenuAction(() => onSelectMenuItem('background_image'))
}
/>
<MenuActionButton
icon={mdiVideo}
label='Background Video'
onClick={() =>
handleMenuAction(() => onSelectMenuItem('background_video'))
}
/>
<MenuActionButton
icon={mdiPanoramaHorizontal}
label='Background 360'
onClick={() =>
handleMenuAction(() => onSelectMenuItem('background_embed'))
}
/>
<MenuActionButton
icon={mdiMusicNote}
label='Background Audio'
onClick={() =>
handleMenuAction(() => onSelectMenuItem('background_audio'))
}
/>
</div>
</ClickOutside>
)}
</div>
{/* Elements Dropdown */}
<div className='relative'>
<button
ref={elementsTriggerRef}
type='button'
onClick={() => toggleDropdown('elements')}
className={triggerBtnClass}
>
<BaseIcon path={mdiPlus} size={18} />
<span>Elements</span>
<BaseIcon path={mdiChevronDown} size={16} />
</button>
{activeDropdown === 'elements' && (
<ClickOutside
onClickOutside={closeDropdown}
excludedElements={[elementsTriggerRef]}
<BaseIcon path={mdiPlus} size={18} />
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
</button>
<button
type='button'
onClick={onDuplicatePage}
disabled={!canDuplicatePage}
className={iconBtnClass}
title='Duplicate page'
aria-label='Duplicate page'
>
<div className={dropdownPanelClass}>
<MenuActionButton
icon={mdiSwapHorizontal}
label='Navigation Button'
onClick={() =>
handleMenuAction(() =>
onAddElement(allowedNavigationTypes[0]),
)
}
/>
<MenuActionButton
icon={mdiImageMultiple}
label='Gallery'
onClick={() =>
handleMenuAction(() => onAddElement('gallery'))
}
/>
<MenuActionButton
icon={mdiViewCarousel}
label='Carousel'
onClick={() =>
handleMenuAction(() => onAddElement('carousel'))
}
/>
<MenuActionButton
icon={mdiText}
label='Description'
onClick={() =>
handleMenuAction(() => onAddElement('description'))
}
/>
<MenuActionButton
icon={mdiVideo}
label='Video Player'
onClick={() =>
handleMenuAction(() => onAddElement('video_player'))
}
/>
<MenuActionButton
icon={mdiMusicNote}
label='Audio Player'
onClick={() =>
handleMenuAction(() => onAddElement('audio_player'))
}
/>
<MenuActionButton
icon={mdiInformationOutline}
label='Info Panel'
onClick={() =>
handleMenuAction(() => onAddElement('info_panel'))
}
/>
</div>
</ClickOutside>
)}
<BaseIcon path={mdiContentDuplicate} size={20} />
</button>
<button
type='button'
onClick={onDeletePage}
disabled={!canDeleteCurrentPage}
className='flex h-10 w-10 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35'
title='Delete page'
aria-label='Delete page'
>
<BaseIcon path={mdiDelete} size={20} />
</button>
<div className='relative'>
<button
ref={bgTriggerRef}
type='button'
onClick={() => toggleDropdown('bg')}
className={triggerBtnClass}
>
<BaseIcon path={mdiImageMultiple} size={18} />
<span>BG</span>
<BaseIcon path={mdiChevronDown} size={16} />
</button>
{activeDropdown === 'bg' && (
<ClickOutside
onClickOutside={closeDropdown}
excludedElements={[bgTriggerRef]}
>
<div className={dropdownPanelClass}>
<MenuActionButton
icon={mdiImageMultiple}
label='Background Image'
onClick={() =>
handleMenuAction(() =>
onSelectMenuItem('background_image'),
)
}
/>
<MenuActionButton
icon={mdiVideo}
label='Background Video'
onClick={() =>
handleMenuAction(() =>
onSelectMenuItem('background_video'),
)
}
/>
<MenuActionButton
icon={mdiPanoramaHorizontal}
label='Background 360'
onClick={() =>
handleMenuAction(() =>
onSelectMenuItem('background_embed'),
)
}
/>
<MenuActionButton
icon={mdiMusicNote}
label='Background Audio'
onClick={() =>
handleMenuAction(() =>
onSelectMenuItem('background_audio'),
)
}
/>
</div>
</ClickOutside>
)}
</div>
</div>
</div>
{/* Divider */}
<div className='w-px h-8 bg-white/30' />
<div className={sectionClass}>
<span className={sectionLabelClass}>
Elements actions
</span>
<div className='flex h-10 items-center gap-2'>
<div className='relative'>
<button
ref={elementsTriggerRef}
type='button'
onClick={() => toggleDropdown('elements')}
className={triggerBtnClass}
>
<BaseIcon path={mdiPlus} size={18} />
<span>Elements</span>
<BaseIcon path={mdiChevronDown} size={16} />
</button>
{activeDropdown === 'elements' && (
<ClickOutside
onClickOutside={closeDropdown}
excludedElements={[elementsTriggerRef]}
>
<div className={dropdownPanelClass}>
<MenuActionButton
icon={mdiSwapHorizontal}
label='Navigation Button'
onClick={() =>
handleMenuAction(() =>
onAddElement(allowedNavigationTypes[0]),
)
}
/>
<MenuActionButton
icon={mdiImageMultiple}
label='Gallery'
onClick={() =>
handleMenuAction(() => onAddElement('gallery'))
}
/>
<MenuActionButton
icon={mdiViewCarousel}
label='Carousel'
onClick={() =>
handleMenuAction(() => onAddElement('carousel'))
}
/>
<MenuActionButton
icon={mdiText}
label='Description'
onClick={() =>
handleMenuAction(() => onAddElement('description'))
}
/>
<MenuActionButton
icon={mdiVideo}
label='Video Player'
onClick={() =>
handleMenuAction(() => onAddElement('video_player'))
}
/>
<MenuActionButton
icon={mdiMusicNote}
label='Audio Player'
onClick={() =>
handleMenuAction(() => onAddElement('audio_player'))
}
/>
<MenuActionButton
icon={mdiInformationOutline}
label='Info Panel'
onClick={() =>
handleMenuAction(() => onAddElement('info_panel'))
}
/>
</div>
</ClickOutside>
)}
</div>
<div className='flex items-center gap-1'>
<button
type='button'
onClick={onCopyElement}
disabled={!canCopyCurrentElement}
className={iconBtnClass}
title='Copy selected element'
aria-label='Copy selected element'
>
<BaseIcon path={mdiContentDuplicate} size={20} />
</button>
<button
type='button'
onClick={onPasteElement}
disabled={!canPasteCurrentElement}
className={iconBtnClass}
title='Paste copied element'
aria-label='Paste copied element'
>
<BaseIcon path={mdiContentPaste} size={20} />
</button>
</div>
</div>
</div>
{/* Create Page Button */}
<button
type='button'
onClick={onCreatePage}
disabled={isCreatingPage}
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<BaseIcon path={mdiPlus} size={18} />
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
</button>
<div className='flex h-[58px] items-start gap-2 border-l border-white/15 pt-4 pl-3'>
{/* Save Button - reuse BaseButton with subtitle */}
<BaseButton
small
color='info'
label={isSaving ? 'Saving...' : 'Save'}
subtitle={
lastSavedAt
? dataFormatter.relativeTimestamp(lastSavedAt)
: undefined
}
onClick={onSave}
disabled={isSaving}
/>
{/* Save Button - reuse BaseButton with subtitle */}
<BaseButton
small
color='info'
label={isSaving ? 'Saving...' : 'Save'}
subtitle={
lastSavedAt
? dataFormatter.relativeTimestamp(lastSavedAt)
: undefined
}
onClick={onSave}
disabled={isSaving}
/>
{/* Save to Stage Button */}
<BaseButton
small
color='success'
label={isSavingToStage ? 'Saving...' : 'Stage'}
subtitle={
lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: undefined
}
onClick={onSaveToStage}
disabled={isSavingToStage}
/>
{/* Save to Stage Button */}
<BaseButton
small
color='success'
label={isSavingToStage ? 'Saving...' : 'Stage'}
subtitle={
lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: undefined
}
onClick={onSaveToStage}
disabled={isSavingToStage}
/>
{/* Exit Button */}
<button
type='button'
onClick={onExit}
className='flex h-10 w-10 items-center justify-center rounded text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300'
title='Exit constructor'
>
<BaseIcon path={mdiExitToApp} size={26} />
</button>
{/* Exit Button */}
<button
type='button'
onClick={onExit}
className='flex items-center justify-center w-10 h-10 rounded text-red-400 hover:text-red-300 hover:bg-red-500/20 transition-colors'
title='Exit constructor'
>
<BaseIcon path={mdiExitToApp} size={26} />
</button>
{/* Collapse Toggle */}
<button
type='button'
onClick={() => setIsCollapsed(true)}
className='flex items-center justify-center w-10 h-10 rounded text-white/60 hover:text-white/90 hover:bg-white/20 transition-colors'
title='Collapse toolbar'
>
<BaseIcon path={mdiChevronLeft} size={26} />
</button>
{/* Collapse Toggle */}
<button
type='button'
onClick={() => setIsCollapsed(true)}
className='flex h-10 w-10 items-center justify-center rounded text-white/60 transition-colors hover:bg-white/20 hover:text-white/90'
title='Collapse toolbar'
>
<BaseIcon path={mdiChevronLeft} size={26} />
</button>
</div>
</div>
);
},

View File

@ -36,6 +36,7 @@ const ElementEditorHeader: React.FC<ElementEditorHeaderProps> = ({
<button
type='button'
className='text-[10px] text-white/70 hover:text-white hover:underline'
onMouseDown={(event) => event.stopPropagation()}
onClick={onToggleCollapse}
>
{isCollapsed ? 'Expand' : 'Collapse'}
@ -44,6 +45,7 @@ const ElementEditorHeader: React.FC<ElementEditorHeaderProps> = ({
<button
type='button'
className='text-[10px] text-red-400 hover:text-red-300 hover:underline'
onMouseDown={(event) => event.stopPropagation()}
onClick={onRemove}
>
Remove

View File

@ -19,13 +19,17 @@ const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({
compact = false,
}) => {
const isEditMode = mode === 'edit';
const wrapperClass = compact
? 'flex items-center'
: 'flex flex-wrap items-center gap-2';
const buttonClass = compact ? 'h-10 px-4 py-0' : 'px-3 py-1.5';
return (
<div className='flex flex-wrap items-center gap-2'>
<div className={wrapperClass}>
<div className='inline-flex overflow-hidden rounded border border-white/30 bg-white/10 text-xs font-semibold'>
<button
type='button'
className={`px-3 py-1.5 transition-colors ${
className={`${buttonClass} transition-colors ${
isEditMode
? 'bg-blue-500/80 text-white'
: 'text-white/70 hover:bg-white/10'
@ -36,7 +40,7 @@ const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({
</button>
<button
type='button'
className={`border-l border-white/30 px-3 py-1.5 transition-colors ${
className={`border-l border-white/30 ${buttonClass} transition-colors ${
!isEditMode
? 'bg-blue-500/80 text-white'
: 'text-white/70 hover:bg-white/10'

View File

@ -13,6 +13,7 @@ interface PageSelectorProps {
activePageId: string;
onPageChange: (pageId: string) => void;
disabled?: boolean;
className?: string;
}
const PageSelector: React.FC<PageSelectorProps> = ({
@ -20,6 +21,7 @@ const PageSelector: React.FC<PageSelectorProps> = ({
activePageId,
onPageChange,
disabled = false,
className = '',
}) => {
// Sort pages by sort_order ascending, then by name
const sortedPages = useMemo(() => {
@ -45,7 +47,7 @@ const PageSelector: React.FC<PageSelectorProps> = ({
return (
<select
className='rounded border border-white/30 bg-white/20 pl-3 pr-8 py-1.5 text-sm text-white/90 backdrop-blur-sm focus:outline-none focus:ring-1 focus:ring-white/40 appearance-none bg-no-repeat bg-[length:16px] bg-[right_8px_center]'
className={`rounded border border-white/30 bg-white/20 pl-3 pr-8 py-1.5 text-sm text-white/90 backdrop-blur-sm focus:outline-none focus:ring-1 focus:ring-white/40 appearance-none bg-no-repeat bg-[length:16px] bg-[right_8px_center] ${className}`}
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='rgba(255,255,255,0.7)' d='M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'/%3E%3C/svg%3E")`,
}}

View File

@ -269,6 +269,10 @@ export interface ConstructorToolbarProps {
// Element actions
allowedNavigationTypes: NavigationElementType[];
onAddElement: (type: CanvasElementType) => void;
onCopyElement?: () => void;
onPasteElement?: () => void;
canCopyElement?: boolean;
canPasteElement?: boolean;
// Page actions
onCreatePage: () => void;

View File

@ -132,14 +132,19 @@ export interface ConstructorContextValue {
// Element state
elements: CanvasElement[];
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
getElements: () => CanvasElement[];
selectedElementId: string | null;
selectedElement: CanvasElement | null;
copiedElement: CanvasElement | null;
canPasteElement: boolean;
selectElement: (id: string) => void;
clearSelection: () => void;
updateElement: (id: string, patch: Partial<CanvasElement>) => void;
removeElement: (id: string) => void;
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
removeSelectedElement: () => void;
copySelectedElement: () => void;
pasteCopiedElement: () => CanvasElement | null;
// Menu state
selectedMenuItem: EditorMenuItem;
@ -281,26 +286,36 @@ export function useConstructorElements() {
() => ({
elements: ctx.elements,
setElements: ctx.setElements,
getElements: ctx.getElements,
selectedElementId: ctx.selectedElementId,
selectedElement: ctx.selectedElement,
copiedElement: ctx.copiedElement,
canPasteElement: ctx.canPasteElement,
selectElement: ctx.selectElement,
clearSelection: ctx.clearSelection,
updateElement: ctx.updateElement,
removeElement: ctx.removeElement,
updateSelectedElement: ctx.updateSelectedElement,
removeSelectedElement: ctx.removeSelectedElement,
copySelectedElement: ctx.copySelectedElement,
pasteCopiedElement: ctx.pasteCopiedElement,
}),
[
ctx.elements,
ctx.setElements,
ctx.getElements,
ctx.selectedElementId,
ctx.selectedElement,
ctx.copiedElement,
ctx.canPasteElement,
ctx.selectElement,
ctx.clearSelection,
ctx.updateElement,
ctx.removeElement,
ctx.updateSelectedElement,
ctx.removeSelectedElement,
ctx.copySelectedElement,
ctx.pasteCopiedElement,
],
);
}

View File

@ -5,7 +5,7 @@
* Used in constructor.tsx for adding, updating, and removing UI elements.
*/
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useRef } from 'react';
import type {
CanvasElement,
CanvasElementType,
@ -25,6 +25,7 @@ import {
import {
createDefaultElement,
createLocalId,
cloneElementForPaste,
mergeElementWithDefaults,
isGalleryElementType,
isCarouselElementType,
@ -63,6 +64,10 @@ interface UseConstructorElementsOptions {
onElementAdded?: (element: CanvasElement) => void;
/** Callback when an element is removed */
onElementRemoved?: (elementId: string) => void;
/** Callback when an element is copied */
onElementCopied?: (element: CanvasElement) => void;
/** Callback when an element is pasted */
onElementPasted?: (element: CanvasElement) => void;
}
interface UseConstructorElementsResult {
@ -70,10 +75,16 @@ interface UseConstructorElementsResult {
elements: CanvasElement[];
/** Set elements directly */
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
/** Read the latest elements synchronously, including just-applied updates */
getElements: () => CanvasElement[];
/** Currently selected element ID */
selectedElementId: string;
/** Currently selected element (or null) */
selectedElement: CanvasElement | null;
/** Element copied inside the current constructor session */
copiedElement: CanvasElement | null;
/** Whether an element can be pasted into the current page */
canPasteElement: boolean;
/** Select an element for editing */
selectElement: (elementId: string) => void;
/** Clear selection */
@ -90,6 +101,10 @@ interface UseConstructorElementsResult {
updateElement: (elementId: string, patch: Partial<CanvasElement>) => void;
/** Remove the selected element */
removeSelectedElement: () => void;
/** Copy the selected element into the constructor clipboard */
copySelectedElement: () => void;
/** Paste the copied element into the current page */
pasteCopiedElement: () => CanvasElement | null;
/** Remove an element by ID */
removeElement: (elementId: string) => void;
/** Gallery card operations */
@ -212,8 +227,32 @@ export function useConstructorElements({
onSelectionCleared,
onElementAdded,
onElementRemoved,
onElementCopied,
onElementPasted,
}: UseConstructorElementsOptions = {}): UseConstructorElementsResult {
const [elements, setElements] = useState<CanvasElement[]>(initialElements);
const elementsRef = useRef<CanvasElement[]>(initialElements);
const [copiedElement, setCopiedElement] = useState<CanvasElement | null>(
null,
);
const setElementsWithRef = useCallback(
(value: React.SetStateAction<CanvasElement[]>) => {
const nextElements =
typeof value === 'function'
? (value as (prev: CanvasElement[]) => CanvasElement[])(
elementsRef.current,
)
: value;
elementsRef.current = nextElements;
setElements(nextElements);
onElementsChange?.(nextElements);
},
[onElementsChange],
);
const getElements = useCallback(() => elementsRef.current, []);
// Initialize selectedElementId from route param if element exists in initialElements
const [selectedElementId, setSelectedElementId] = useState(() => {
if (
@ -229,6 +268,7 @@ export function useConstructorElements({
() => elements.find((el) => el.id === selectedElementId) || null,
[elements, selectedElementId],
);
const canPasteElement = Boolean(copiedElement);
const selectElement = useCallback(
(elementId: string) => {
@ -255,9 +295,8 @@ export function useConstructorElements({
const defaults = elementDefaultsByType[effectiveType];
const newElement = mergeElementWithDefaults(baseElement, defaults);
setElements((prev) => {
setElementsWithRef((prev) => {
const next = [...prev, newElement];
onElementsChange?.(next);
return next;
});
setSelectedElementId(newElement.id);
@ -268,7 +307,7 @@ export function useConstructorElements({
allowedNavigationTypes,
elements.length,
elementDefaultsByType,
onElementsChange,
setElementsWithRef,
onElementSelected,
onElementAdded,
],
@ -282,31 +321,29 @@ export function useConstructorElements({
) => {
if (!selectedElementId) return;
setElements((prev) => {
setElementsWithRef((prev) => {
const next = prev.map((el) => {
if (el.id !== selectedElementId) return el;
const patch =
typeof patchOrFn === 'function' ? patchOrFn(el) : patchOrFn;
return { ...el, ...patch };
});
onElementsChange?.(next);
return next;
});
},
[selectedElementId, onElementsChange],
[selectedElementId, setElementsWithRef],
);
const updateElement = useCallback(
(elementId: string, patch: Partial<CanvasElement>) => {
setElements((prev) => {
setElementsWithRef((prev) => {
const next = prev.map((el) =>
el.id === elementId ? { ...el, ...patch } : el,
);
onElementsChange?.(next);
return next;
});
},
[onElementsChange],
[setElementsWithRef],
);
const removeSelectedElement = useCallback(() => {
@ -314,10 +351,9 @@ export function useConstructorElements({
const removedId = selectedElementId;
let nextSelectedId = '';
setElements((prev) => {
setElementsWithRef((prev) => {
const filtered = prev.filter((el) => el.id !== selectedElementId);
nextSelectedId = filtered[0]?.id || '';
onElementsChange?.(filtered);
return filtered;
});
@ -331,7 +367,7 @@ export function useConstructorElements({
onElementRemoved?.(removedId);
}, [
selectedElementId,
onElementsChange,
setElementsWithRef,
onElementSelected,
onSelectionCleared,
onElementRemoved,
@ -339,9 +375,8 @@ export function useConstructorElements({
const removeElement = useCallback(
(elementId: string) => {
setElements((prev) => {
setElementsWithRef((prev) => {
const next = prev.filter((el) => el.id !== elementId);
onElementsChange?.(next);
return next;
});
@ -349,18 +384,46 @@ export function useConstructorElements({
setSelectedElementId('');
}
},
[selectedElementId, onElementsChange],
[selectedElementId, setElementsWithRef],
);
const copySelectedElement = useCallback(() => {
if (!selectedElement) return;
const clonedForClipboard = cloneElementForPaste(selectedElement);
setCopiedElement(clonedForClipboard);
onElementCopied?.(selectedElement);
}, [selectedElement, onElementCopied]);
const pasteCopiedElement = useCallback(() => {
if (!copiedElement) return null;
const pastedElement = cloneElementForPaste(copiedElement);
setElementsWithRef((prev) => {
const next = [...prev, pastedElement];
return next;
});
setSelectedElementId(pastedElement.id);
onElementSelected?.(pastedElement.id);
onElementPasted?.(pastedElement);
return pastedElement;
}, [
copiedElement,
onElementSelected,
onElementPasted,
setElementsWithRef,
]);
const updateElementPosition = useCallback(
(elementId: string, xPercent: number, yPercent: number) => {
setElements((prev) =>
setElementsWithRef((prev) =>
prev.map((el) =>
el.id === elementId ? { ...el, xPercent, yPercent } : el,
),
);
},
[],
[setElementsWithRef],
);
// Gallery card operations
@ -678,15 +741,20 @@ export function useConstructorElements({
return {
elements,
setElements,
setElements: setElementsWithRef,
getElements,
selectedElementId,
selectedElement,
copiedElement,
canPasteElement,
selectElement,
clearSelection,
addElement,
updateSelectedElement,
updateElement,
removeSelectedElement,
copySelectedElement,
pasteCopiedElement,
removeElement,
galleryCards,
galleryInfoSpans,

View File

@ -60,6 +60,8 @@ interface UseConstructorPageActionsOptions {
activePageId: string;
/** Current elements array */
elements: CanvasElement[];
/** Latest elements getter for same-tick paste/save flows */
getElements?: () => CanvasElement[];
/** Consolidated page background state */
pageBackground: PageBackgroundState;
/** Callback to reload data after operations */
@ -126,6 +128,7 @@ export function useConstructorPageActions({
activePage,
activePageId,
elements,
getElements,
pageBackground,
onReload,
onSetActivePageId,
@ -194,10 +197,11 @@ export function useConstructorPageActions({
try {
setIsSaving(true);
const elementsToSave = getElements ? getElements() : elements;
// Capture pending reverse video keys BEFORE save
// These are elements that will trigger async reversed video generation
const pendingReverseKeys = getPendingReverseVideoKeys(elements);
const pendingReverseKeys = getPendingReverseVideoKeys(elementsToSave);
const existingSchema = parseJsonObject<Record<string, any>>(
activePage?.ui_schema_json,
@ -205,7 +209,7 @@ export function useConstructorPageActions({
);
const schemaToSave = {
...existingSchema,
elements,
elements: elementsToSave,
};
await axios.put(`/tour_pages/${activePageId}`, {
@ -279,6 +283,7 @@ export function useConstructorPageActions({
activePageId,
pageBackground,
elements,
getElements,
project?.design_width,
project?.design_height,
onError,

View File

@ -67,6 +67,68 @@ export const createLocalId = (): string => {
return `element_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
const deepCloneElement = (element: CanvasElement): CanvasElement => {
if (typeof structuredClone === 'function') {
return structuredClone(element);
}
return JSON.parse(JSON.stringify(element)) as CanvasElement;
};
/**
* Deep-copy an existing element as a new independent canvas instance.
*
* All settings, media, navigation targets, transitions, styles, effects, and
* positions are preserved. Only element-instance IDs are regenerated.
*/
export const cloneElementForPaste = (element: CanvasElement): CanvasElement => {
const cloned = deepCloneElement(element);
cloned.id = createLocalId();
if (Array.isArray(cloned.galleryCards)) {
cloned.galleryCards = cloned.galleryCards.map((card) => ({
...card,
id: createLocalId(),
}));
}
if (Array.isArray(cloned.galleryInfoSpans)) {
cloned.galleryInfoSpans = cloned.galleryInfoSpans.map((span) => ({
...span,
id: createLocalId(),
}));
}
if (Array.isArray(cloned.carouselSlides)) {
cloned.carouselSlides = cloned.carouselSlides.map((slide) => ({
...slide,
id: createLocalId(),
}));
}
if (Array.isArray(cloned.infoPanelSections)) {
cloned.infoPanelSections = cloned.infoPanelSections.map((section) => ({
...section,
id: createLocalId(),
spans: Array.isArray(section.spans)
? section.spans.map((span) => ({
...span,
id: createLocalId(),
}))
: section.spans,
images: Array.isArray(section.images)
? section.images.map((image) => ({
...image,
id: createLocalId(),
}))
: section.images,
}));
}
return cloned;
};
/**
* Clamp a number between min and max
*/

View File

@ -136,6 +136,19 @@ const sortTourPagesForDisplay = (items: TourPage[]) =>
return (a.name || '').localeCompare(b.name || '');
});
const isEditableShortcutTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return (
tagName === 'input' ||
tagName === 'textarea' ||
tagName === 'select' ||
target.isContentEditable ||
Boolean(target.closest('[contenteditable="true"]'))
);
};
// Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup
const labelByType = ELEMENT_TYPE_LABELS;
@ -335,13 +348,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const {
elements,
setElements,
getElements,
selectedElementId,
selectedElement,
copiedElement,
canPasteElement,
selectElement,
clearSelection,
addElement,
updateSelectedElement,
removeSelectedElement,
copySelectedElement,
pasteCopiedElement,
galleryCards,
galleryInfoSpans,
carouselSlides,
@ -366,6 +384,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onElementRemoved: useCallback(() => {
setSuccessMessage('Element removed.');
}, []),
onElementCopied: useCallback(() => {
setSuccessMessage('Element copied.');
setErrorMessage('');
}, []),
onElementPasted: useCallback(() => {
setSuccessMessage('Element pasted.');
setErrorMessage('');
}, []),
});
// Check if any element has full-width carousel mode enabled
@ -1022,6 +1048,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
activePage,
activePageId,
elements,
getElements,
pageBackground,
onReload: handleReload,
onSetActivePageId: setActivePageId,
@ -1545,6 +1572,42 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
[clearSelection],
);
useEffect(() => {
if (!isConstructorEditMode || isLoading || !activePageId) return;
const handleElementClipboardShortcut = (event: KeyboardEvent) => {
if (!event.metaKey && !event.ctrlKey) return;
if (event.altKey || event.shiftKey) return;
if (isEditableShortcutTarget(event.target)) return;
const shortcutKey = event.key.toLowerCase();
if (shortcutKey === 'c') {
if (!selectedElement) return;
event.preventDefault();
copySelectedElement();
return;
}
if (shortcutKey === 'v') {
if (!canPasteElement) return;
event.preventDefault();
pasteCopiedElement();
}
};
window.addEventListener('keydown', handleElementClipboardShortcut);
return () =>
window.removeEventListener('keydown', handleElementClipboardShortcut);
}, [
activePageId,
canPasteElement,
copySelectedElement,
isConstructorEditMode,
isLoading,
pasteCopiedElement,
selectedElement,
]);
// createPage, saveConstructor, saveToStage are now provided by useConstructorPageActions hook
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
@ -1973,8 +2036,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Element state
elements,
setElements,
getElements,
selectedElementId,
selectedElement,
copiedElement,
canPasteElement,
selectElement,
clearSelection,
updateElement: (id: string, patch: Partial<CanvasElement>) => {
@ -1990,6 +2056,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
},
updateSelectedElement,
removeSelectedElement,
copySelectedElement,
pasteCopiedElement,
// Menu state
selectedMenuItem,
@ -2048,12 +2116,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
setBackgroundVideoSettings,
elements,
setElements,
getElements,
selectedElementId,
selectedElement,
copiedElement,
canPasteElement,
selectElement,
clearSelection,
updateSelectedElement,
removeSelectedElement,
copySelectedElement,
pasteCopiedElement,
selectedMenuItem,
isMenuOpen,
elementEditorTab,
@ -2139,6 +2212,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onSelectMenuItem={selectMenuItemForEdit}
allowedNavigationTypes={allowedNavigationTypes}
onAddElement={addElement}
onCopyElement={copySelectedElement}
onPasteElement={pasteCopiedElement}
canCopyElement={Boolean(selectedElement)}
canPasteElement={canPasteElement}
onCreatePage={handleShowCreatePageModal}
isCreatingPage={isCreatingPage}
onSave={saveConstructor}