feature request: copy paste or duplicate feature
currently if i already made a nice looking block according to my needs, i need to redo the same steps again for the 2nd block.
copy paste or duplicate feature will make things faster, as i can simply copy paste or duplicate the block, and then modify it.
Good idea. This is something we support on our 'Pro' builder on Waypoint (see screenshot below) but haven't had the chance to port it over to EmailBuilder.js yet. I think this would be a good addition. Marking this as 'good first issue' in case others can help in the meantime.
Hi, I'd love to contribute to this feature! I’m planning to implement the duplication functionality. Could you confirm if I should add this to the editor sample package? Is that the correct one?
@Abhi-Bhat18 yes, that's correct. Thanks!
Hi, I'd love to contribute to this feature! I’m planning to implement the duplication functionality. Could you confirm if I should add this to the editor sample package? Is that the correct one?
Really looking forward to this feature, thank you!!
I tried using this and couldn't get it to work. Did you also make changes to tsconfig and add other packages? I've been able to write my own, but it doesn't duplicate nested content objects, like blocks within a column block.
@dholle02
I know this is a bit late, but I got it working. For me, it is duplicating nested items, items in all container types and items on their own.
So for anyone feel free to use this but keep in mind original credit has to go to @quyphan97 who was so kind to provide the basis of this approach. So to @quyphan97 I want to say thank you very much for providing your code!
PS: This document is still a WIP, so don't expect everything to be spotless.
import React from 'react';
import { ArrowDownwardOutlined, ArrowUpwardOutlined, DeleteOutlined, ContentCopyOutlined } from '@mui/icons-material';
import { IconButton, Paper, Stack, SxProps, Tooltip } from '@mui/material';
import { TEditorBlock } from '../../../editor/core';
import { resetDocument, setSelectedBlockId, useDocument } from '../../../editor/EditorContext';
import { ColumnsContainerProps } from '../../ColumnsContainer/ColumnsContainerPropsSchema';
const sx: SxProps = {
position: 'absolute',
top: 0,
left: -56,
borderRadius: 64,
paddingX: 0.5,
paddingY: 1,
zIndex: 'fab',
};
type Props = {
blockId: string;
};
export default function TuneMenu({ blockId }: Props) {
const document = useDocument();
const generateNewId = (prefix = 'block') => `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
const cloneBlockRecursively = (blockId: string, document: any, idMapping: any = {}) => {
const originalBlock = document[blockId];
const newBlockId = generateNewId(blockId.split('-')[0]);
idMapping[blockId] = newBlockId;
const clonedBlock = JSON.parse(JSON.stringify(originalBlock));
clonedBlock.id = newBlockId;
const updateAndCloneChildrenIds = (childrenIds: any) => {
if (!childrenIds) return childrenIds;
return childrenIds.map((childId: any) => {
if (!idMapping[childId]) {
const { newDocument, newMapping } = cloneBlockRecursively(childId, document, idMapping);
Object.assign(document, newDocument);
Object.assign(idMapping, newMapping);
}
return idMapping[childId];
});
};
if (clonedBlock.data?.props?.childrenIds) {
clonedBlock.data.props.childrenIds = updateAndCloneChildrenIds(clonedBlock.data.props.childrenIds);
}
if (clonedBlock.data?.props?.columns) {
clonedBlock.data.props.columns = clonedBlock.data.props.columns.map((column: any) => ({
...column,
childrenIds: updateAndCloneChildrenIds(column.childrenIds),
}));
}
return {
newDocument: { ...document, [newBlockId]: clonedBlock },
newMapping: idMapping,
newBlockId,
};
};
const handleDuplicateClick = () => {
const { newDocument, newBlockId } = cloneBlockRecursively(blockId, document);
// Find the parent block and update its children IDs or columns
const parentBlockId = Object.keys(newDocument).find((id) => {
const block = newDocument[id];
return (
(block.data?.childrenIds && block.data.childrenIds.includes(blockId)) ||
(block.data?.props?.childrenIds && block.data.props.childrenIds.includes(blockId)) ||
(block.data?.props?.columns && block.data.props.columns.some((col: any) => col.childrenIds?.includes(blockId)))
);
});
if (parentBlockId) {
const parentBlock: any = newDocument[parentBlockId] as TEditorBlock;
if (parentBlock.data?.childrenIds) {
const index = parentBlock.data.childrenIds.indexOf(blockId);
parentBlock.data.childrenIds.splice(index + 1, 0, newBlockId);
} else if (parentBlock.data?.props?.childrenIds) {
const index = parentBlock.data.props.childrenIds.indexOf(blockId);
parentBlock.data.props.childrenIds.splice(index + 1, 0, newBlockId);
} else if (parentBlock.data?.props?.columns) {
for (const column of parentBlock.data.props.columns) {
if (column.childrenIds?.includes(blockId)) {
const index = column.childrenIds.indexOf(blockId);
column.childrenIds.splice(index + 1, 0, newBlockId);
break;
}
}
}
resetDocument(newDocument);
setSelectedBlockId(newBlockId);
}
};
const handleDeleteClick = () => {
const filterChildrenIds = (childrenIds: string[] | null | undefined) => {
if (!childrenIds) return childrenIds;
return childrenIds.filter((f) => f !== blockId);
};
const nDocument: typeof document = { ...document };
for (const [id, b] of Object.entries(nDocument)) {
const block = b as TEditorBlock;
if (id === blockId) continue;
switch (block.type) {
case 'EmailLayout':
nDocument[id] = {
...block,
data: {
...block.data,
childrenIds: filterChildrenIds(block.data.childrenIds),
},
};
break;
case 'Container':
nDocument[id] = {
...block,
data: {
...block.data,
props: {
...block.data.props,
childrenIds: filterChildrenIds(block.data.props?.childrenIds),
},
},
};
break;
case 'ColumnsContainer':
nDocument[id] = {
type: 'ColumnsContainer',
data: {
style: block.data.style,
props: {
...block.data.props,
columns: block.data.props?.columns?.map((c) => ({
childrenIds: filterChildrenIds(c.childrenIds),
})),
},
} as ColumnsContainerProps,
};
break;
default:
nDocument[id] = block;
}
}
delete nDocument[blockId];
resetDocument(nDocument);
};
const handleMoveClick = (direction: 'up' | 'down') => {
const moveChildrenIds = (ids: string[] | null | undefined) => {
if (!ids) return ids;
const index = ids.indexOf(blockId);
if (index < 0) return ids;
const childrenIds = [...ids];
if (direction === 'up' && index > 0) {
[childrenIds[index], childrenIds[index - 1]] = [childrenIds[index - 1], childrenIds[index]];
} else if (direction === 'down' && index < childrenIds.length - 1) {
[childrenIds[index], childrenIds[index + 1]] = [childrenIds[index + 1], childrenIds[index]];
}
return childrenIds;
};
const nDocument: typeof document = { ...document };
for (const [id, b] of Object.entries(nDocument)) {
const block = b as TEditorBlock;
if (id === blockId) continue;
switch (block.type) {
case 'EmailLayout':
nDocument[id] = {
...block,
data: {
...block.data,
childrenIds: moveChildrenIds(block.data.childrenIds),
},
};
break;
case 'Container':
nDocument[id] = {
...block,
data: {
...block.data,
props: {
...block.data.props,
childrenIds: moveChildrenIds(block.data.props?.childrenIds),
},
},
};
break;
case 'ColumnsContainer':
nDocument[id] = {
type: 'ColumnsContainer',
data: {
style: block.data.style,
props: {
...block.data.props,
columns: block.data.props?.columns?.map((c) => ({
childrenIds: moveChildrenIds(c.childrenIds),
})),
},
} as ColumnsContainerProps,
};
break;
default:
nDocument[id] = block;
}
}
resetDocument(nDocument);
setSelectedBlockId(blockId);
};
return (
<Paper sx={sx} onClick={(ev) => ev.stopPropagation()}>
<Stack>
<Tooltip title="Move up" placement="left-start">
<IconButton onClick={() => handleMoveClick('up')} sx={{ color: 'text.primary' }}>
<ArrowUpwardOutlined fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Move down" placement="left-start">
<IconButton onClick={() => handleMoveClick('down')} sx={{ color: 'text.primary' }}>
<ArrowDownwardOutlined fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Duplicate" placement="left-start">
<IconButton onClick={handleDuplicateClick} sx={{ color: 'text.primary' }}>
<ContentCopyOutlined fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete" placement="left-start">
<IconButton onClick={handleDeleteClick} sx={{ color: 'text.primary' }}>
<DeleteOutlined fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Paper>
);
}