Skip to content

Commit e3bb064

Browse files
authored
Merge pull request #11 from BenoitPrmt/feature/duplicate-block
(Issue #9) feat: added block duplicating by button or shortcut (ctrl+d)
2 parents 8d2f2a0 + bfee87b commit e3bb064

File tree

2 files changed

+154
-49
lines changed

2 files changed

+154
-49
lines changed

components/Block.tsx

Lines changed: 76 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import React, { useEffect, useState, useRef, useCallback } from 'react';
22
import { BlockData, BlockType } from '../types';
3-
import { Youtube, MoveVertical, Play, Loader2, Pencil, Move, Check, X, Trash2 } from 'lucide-react';
3+
import {
4+
Youtube,
5+
MoveVertical,
6+
Play,
7+
Loader2,
8+
Pencil,
9+
Move,
10+
Check,
11+
X,
12+
Trash2,
13+
CopyPlus,
14+
} from 'lucide-react';
415
import { motion } from 'framer-motion';
516
import { getSocialPlatformOption, inferSocialPlatformFromUrl } from '../socialPlatforms';
6-
import {
7-
openSafeUrl,
8-
isValidYouTubeChannelId,
9-
isValidLocationString,
10-
sanitizeUrl,
11-
} from '../utils/security';
17+
import { openSafeUrl, isValidYouTubeChannelId, isValidLocationString } from '../utils/security';
1218

1319
// Apple TV style 3D tilt effect hook
1420
const useTiltEffect = (isEnabled: boolean = true) => {
@@ -78,6 +84,7 @@ interface BlockProps {
7884
onDragEnter: (id: string) => void;
7985
onDragEnd: () => void;
8086
onDrop: (id: string) => void;
87+
onDuplicate?: (id: string) => void;
8188
enableResize?: boolean;
8289
isResizing?: boolean;
8390
onResizeStart?: (block: BlockData, e: React.PointerEvent<HTMLButtonElement>) => void;
@@ -97,6 +104,7 @@ const Block: React.FC<BlockProps> = ({
97104
onDragEnter,
98105
onDragEnd,
99106
onDrop,
107+
onDuplicate,
100108
enableResize,
101109
isResizing,
102110
onResizeStart,
@@ -402,6 +410,9 @@ const Block: React.FC<BlockProps> = ({
402410
</button>
403411
) : null;
404412

413+
const showActionButtons = !previewMode && (!!onDuplicate || !!onDelete);
414+
const repositionButtonOffsetClass = showActionButtons ? 'top-12' : 'top-2';
415+
405416
// Explicit grid positioning (if defined)
406417
const gridPositionStyle: React.CSSProperties = {};
407418
if (block.gridColumn !== undefined) {
@@ -550,19 +561,34 @@ const Block: React.FC<BlockProps> = ({
550561
</span>
551562
) : null}
552563

553-
{/* Delete button - appears on hover (not in preview mode) */}
554-
{!previewMode && (
555-
<button
556-
onClick={(e) => {
557-
e.preventDefault();
558-
e.stopPropagation();
559-
onDelete(block.id);
560-
}}
561-
className="absolute top-1 left-1 p-1 bg-red-500/80 hover:bg-red-600 text-white rounded-md opacity-0 group-hover:opacity-100 transition-opacity z-20"
562-
title="Delete"
563-
>
564-
<Trash2 size={12} />
565-
</button>
564+
{/* Action buttons */}
565+
{showActionButtons && (
566+
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-20">
567+
{onDuplicate && (
568+
<button
569+
onClick={(e) => {
570+
e.preventDefault();
571+
e.stopPropagation();
572+
onDuplicate(block.id);
573+
}}
574+
className="p-1 bg-white/90 text-gray-800 rounded-md shadow-sm hover:bg-white"
575+
title="Duplicate block"
576+
>
577+
<CopyPlus size={12} />
578+
</button>
579+
)}
580+
<button
581+
onClick={(e) => {
582+
e.preventDefault();
583+
e.stopPropagation();
584+
onDelete(block.id);
585+
}}
586+
className="p-1 bg-red-500/80 hover:bg-red-600 text-white rounded-md shadow-sm"
587+
title="Delete block"
588+
>
589+
<Trash2 size={12} />
590+
</button>
591+
</div>
566592
)}
567593

568594
{resizeHandle}
@@ -608,24 +634,13 @@ const Block: React.FC<BlockProps> = ({
608634
// ===== YOUTUBE GRID/LIST LAYOUT (ADAPTIVE) =====
609635
if (isYoutubeGrid || isYoutubeList) {
610636
// Adaptive layout based on block size
611-
const isLargeBlock = block.colSpan >= 2 && block.rowSpan >= 2; // 2x2 or larger
612637
const isWideBlock = block.colSpan >= 2 && block.rowSpan === 1; // 2x1
613-
const isTallBlock = block.colSpan === 1 && block.rowSpan >= 2; // 1x2
614638
const isSmallBlock = block.colSpan === 1 && block.rowSpan === 1; // 1x1
615639

616640
// Determine display mode based on size
617-
const showTitles = isLargeBlock || isTallBlock;
618641
const videosToShow = isSmallBlock ? 2 : isWideBlock ? 2 : 4;
619642
const displayVideos = activeVideos.slice(0, videosToShow);
620643

621-
// Grid configuration
622-
const getGridClass = () => {
623-
if (isSmallBlock) return 'grid grid-cols-2 gap-1.5';
624-
if (isWideBlock) return 'grid grid-cols-2 gap-2';
625-
if (isTallBlock) return 'flex flex-col gap-2';
626-
return 'grid grid-cols-2 gap-2'; // Large block
627-
};
628-
629644
return (
630645
<motion.div
631646
layoutId={block.id}
@@ -824,19 +839,34 @@ const Block: React.FC<BlockProps> = ({
824839
}}
825840
/>
826841
)}
827-
{/* Delete button - appears on hover (not in preview mode) */}
828-
{!previewMode && (
829-
<button
830-
onClick={(e) => {
831-
e.preventDefault();
832-
e.stopPropagation();
833-
onDelete(block.id);
834-
}}
835-
className="absolute top-2 left-2 p-1.5 bg-red-500/80 hover:bg-red-600 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity z-20 backdrop-blur-sm"
836-
title="Delete block"
837-
>
838-
<Trash2 size={14} />
839-
</button>
842+
{/* Action buttons */}
843+
{showActionButtons && (
844+
<div className="absolute top-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-30 pointer-events-auto">
845+
{onDuplicate && (
846+
<button
847+
onClick={(e) => {
848+
e.preventDefault();
849+
e.stopPropagation();
850+
onDuplicate(block.id);
851+
}}
852+
className="p-2 bg-white/80 hover:bg-white text-gray-800 rounded-lg shadow-sm backdrop-blur-sm"
853+
title="Duplicate block"
854+
>
855+
<CopyPlus size={14} />
856+
</button>
857+
)}
858+
<button
859+
onClick={(e) => {
860+
e.preventDefault();
861+
e.stopPropagation();
862+
onDelete(block.id);
863+
}}
864+
className="p-2 bg-red-500/80 hover:bg-red-600 text-white rounded-lg backdrop-blur-sm shadow-sm"
865+
title="Delete block"
866+
>
867+
<Trash2 size={14} />
868+
</button>
869+
</div>
840870
)}
841871

842872
{resizeHandle}
@@ -859,7 +889,7 @@ const Block: React.FC<BlockProps> = ({
859889
e.stopPropagation();
860890
setIsRepositioning(true);
861891
}}
862-
className="absolute top-2 right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity z-20 backdrop-blur-sm"
892+
className={`absolute ${repositionButtonOffsetClass} right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity z-20 backdrop-blur-sm`}
863893
title="Reposition image"
864894
>
865895
<Move size={16} />
@@ -945,7 +975,7 @@ const Block: React.FC<BlockProps> = ({
945975
e.stopPropagation();
946976
setIsRepositioning(true);
947977
}}
948-
className="absolute top-2 right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto z-20 backdrop-blur-sm"
978+
className={`absolute ${repositionButtonOffsetClass} right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto z-20 backdrop-blur-sm`}
949979
title="Reposition media"
950980
>
951981
<Move size={16} />

components/Builder.tsx

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import React, { useState, useEffect, useCallback, useRef } from 'react';
2-
import { SiteData, UserProfile, BlockData, BlockType, SavedBento, AvatarStyle } from '../types';
2+
import { UserProfile, BlockData, BlockType, SavedBento, AvatarStyle } from '../types';
33
import Block from './Block';
44
import EditorSidebar from './EditorSidebar';
55
import ProfileDropdown from './ProfileDropdown';
66
import SettingsModal from './SettingsModal';
77
import ImageCropModal from './ImageCropModal';
88
import AvatarStyleModal from './AvatarStyleModal';
9-
import { exportSite, type ExportDeploymentTarget } from '../services/export';
9+
import { exportSite, type ExportDeploymentTarget } from '@/services/export';
1010
import {
1111
initializeApp,
1212
updateBentoData,
1313
setActiveBentoId,
14-
getBento,
1514
downloadBentoJSON,
1615
loadBentoFromFile,
1716
renameBento,
@@ -653,6 +652,81 @@ const Builder: React.FC<BuilderProps> = ({ onBack }) => {
653652
if (editingBlockId === id) setEditingBlockId(null);
654653
};
655654

655+
const duplicateBlock = useCallback(
656+
(id: string) => {
657+
let duplicated: BlockData | null = null;
658+
659+
handleSetBlocks((prev) => {
660+
const source = prev.find((b) => b.id === id);
661+
if (!source) return prev;
662+
663+
const generateId = () => {
664+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
665+
return crypto.randomUUID();
666+
}
667+
return Math.random().toString(36).slice(2, 11);
668+
};
669+
670+
const clone: BlockData = {
671+
...source,
672+
id: generateId(),
673+
gridColumn: undefined,
674+
gridRow: undefined,
675+
zIndex: undefined,
676+
mediaPosition: source.mediaPosition ? { ...source.mediaPosition } : undefined,
677+
youtubeVideos: source.youtubeVideos
678+
? source.youtubeVideos.map((vid) => ({ ...vid }))
679+
: undefined,
680+
};
681+
682+
const occupiedCells = getOccupiedCells(prev);
683+
const startRow = source.gridRow ?? 1;
684+
const position = findNextAvailablePosition(clone, occupiedCells, startRow);
685+
686+
clone.gridColumn = position.col;
687+
clone.gridRow = position.row;
688+
689+
duplicated = clone;
690+
return [...prev, clone];
691+
});
692+
693+
if (duplicated) {
694+
setEditingBlockId(duplicated.id);
695+
if (!isSidebarOpen) setIsSidebarOpen(true);
696+
}
697+
},
698+
[handleSetBlocks, isSidebarOpen]
699+
);
700+
701+
useEffect(() => {
702+
const handleKeyDown = (event: KeyboardEvent) => {
703+
if (event.defaultPrevented) return;
704+
if (!(event.metaKey || event.ctrlKey)) return;
705+
if (event.key.toLowerCase() !== 'd') return;
706+
if (!editingBlockId) return;
707+
708+
const activeElement = (document.activeElement as HTMLElement) || null;
709+
const targetElement = (event.target as HTMLElement) || null;
710+
const shouldSkip =
711+
(activeElement &&
712+
(activeElement.tagName === 'INPUT' ||
713+
activeElement.tagName === 'TEXTAREA' ||
714+
activeElement.isContentEditable)) ||
715+
(targetElement &&
716+
(targetElement.tagName === 'INPUT' ||
717+
targetElement.tagName === 'TEXTAREA' ||
718+
targetElement.isContentEditable));
719+
720+
if (shouldSkip) return;
721+
722+
event.preventDefault();
723+
duplicateBlock(editingBlockId);
724+
};
725+
726+
window.addEventListener('keydown', handleKeyDown);
727+
return () => window.removeEventListener('keydown', handleKeyDown);
728+
}, [duplicateBlock, editingBlockId]);
729+
656730
const handleExport = () => {
657731
setHasDownloadedExport(false);
658732
setExportError(null);
@@ -1884,6 +1958,7 @@ const Builder: React.FC<BuilderProps> = ({ onBack }) => {
18841958
onDragEnter={handleDragEnter}
18851959
onDragEnd={handleDragEnd}
18861960
onDrop={handleDrop}
1961+
onDuplicate={duplicateBlock}
18871962
onInlineUpdate={updateBlock}
18881963
/>
18891964
))}

0 commit comments

Comments
 (0)