email-builder-js icon indicating copy to clipboard operation
email-builder-js copied to clipboard

feature request: copy paste or duplicate feature

Open orangpelupa opened this issue 1 year ago • 7 comments

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.

orangpelupa avatar Sep 17 '24 07:09 orangpelupa

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.

CleanShot 2024-09-17 at 09 29 54@2x

jordanisip avatar Sep 17 '24 13:09 jordanisip

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?

bhat-abhishek avatar Sep 30 '24 09:09 bhat-abhishek

@Abhi-Bhat18 yes, that's correct. Thanks!

jordanisip avatar Oct 01 '24 20:10 jordanisip

TuneMenu.txt

quyphan97 avatar Nov 08 '24 03:11 quyphan97

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

jmock43 avatar Dec 01 '24 02:12 jmock43

TuneMenu.txt

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 avatar Dec 17 '24 19:12 dholle02

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

hennysmafter avatar Mar 05 '25 23:03 hennysmafter