DEV Community

It's Just Nifty
It's Just Nifty

Posted on • Originally published at niftylittleme.com on

How To Add Drag-And-Drop Functionality With Editable Draggable Items In Next.js

Note: In this tutorial, we will be using the Next.js app router.

If you read or scanned one of my previous articles, you would likely have drag-and-drop functionality in your project. But what if you want to drag-and-drop things that aren’t just little boxes? What if you actually want to drag-and-drop elements? You know, something cooler? Well then, this tutorial is for you.

But before we can start, you would need to read my previous article to understand what exactly we’re doing here. So, check that out and then come back. I have a lot of code to share with you all. So, let’s dive in!

Unsplash Image by Kevin Ku

Previously, On the Nifty Little Me blog

The drag-and-drop libraries and kits out there suck, so we needed to add our own drag-and-drop functionality from scratch in our Next.js projects. We create draggable items, a grid drop zone with zoom in/out functionality, and display our draggable items and drop zone.

We will change the DropZone.tsxcode and src/app/page.tsx code.

Changing Our Drop Zone Code

So far, in the DropZone.tsx code, we are creating a grid with zoom-in and out functionality. Now, we need to handle the case where we have different types of items being dragged to the grid.

The first thing we would want to do is define the structure of props passed to the drop zone component:

type DropZoneProps = {
  onDrop: (id: string, value?: string, src?: string) => void;
};
Enter fullscreen mode Exit fullscreen mode

We also need to define a class that represents an item. In this tutorial, the three items we will have are textareas, tables, and images.

type GridItem = {
  id: string;
  value?: string;
  type: 'text' | 'table' | 'image';
  src?: string;
  width?: number;
  height?: number;
};
Enter fullscreen mode Exit fullscreen mode

Let’s also change the handleDrop function to handle the case of different item types:

const handleDrop = (e: React.DragEvent<HTMLDivElement>, index: number) => {
    e.preventDefault();
    const id = e.dataTransfer.getData('text/plain');
    const value = e.dataTransfer.getData('text/value');
    const src = e.dataTransfer.getData('text/src');
    const type = id.includes('table') ? 'table' : id.includes('image') ? 'image' : 'text';
    const newGrid = [...grid];
    if (draggedItemIndex !== null && draggedItemIndex !== index) {
      newGrid[draggedItemIndex] = null;
      newGrid[index] = { id, value, type, src };
      setDraggedItemIndex(null);
    } else {
      newGrid[index] = { id, value, type, src }; 
    }
    setGrid(newGrid);
    onDrop(id, value, src);
  };
Enter fullscreen mode Exit fullscreen mode

We also need to handle the change in text for the textarea item and image resizing for the image item:

  const handleTextChange = (index: number, value: string) => {
    const newGrid = [...grid];
    if (newGrid[index]) {
      newGrid[index]!.value = value;
      setGrid(newGrid);
    }
  };

  const handleImageResize = (index: number, newWidth: number) => {
    const newGrid = [...grid];
    if (newGrid[index] && newGrid[index]!.type === 'image') {
      newGrid[index] = {
        ...newGrid[index]!,
        width: newWidth
      };
      setGrid(newGrid);
    }
  };
Enter fullscreen mode Exit fullscreen mode

Now, we need to display the different item types in the drop zone:

  return (
    <div>
      <div className="zoom-controls">
        <button onClick={handleZoomIn}>Zoom In</button>
        <button onClick={handleZoomOut}>Zoom Out</button>
      </div>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: `repeat(${gridSize}, 1fr)`,
          border: '2px dashed #ccc',
        }}
        className='w-full h-screen overflow-y-auto overflow-x-auto'
      >
        {grid.map((item, index) => (
          <div
            key={index}
            onDrop={(e) => handleDrop(e, index)}
            onDragOver={handleDragOver}
            style={{
              width: '100%',
              height: '100%',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              backgroundColor: item ? '#e0e0e0' : 'transparent',
              border: '1px solid #ccc',
              position: 'relative'
            }}
          >
            {item ? (
              <DraggableItem id={item.id} onDragStart={() => handleDragStart(index)}>
                {item.type === 'text' ? (
                  <textarea
                    value={item.value}
                    onChange={(e) => handleTextChange(index, e.target.value)}
                    placeholder='Start Typing...'
                    style={{
                      padding: '10px',
                      margin: '5px',
                      border: '1px solid #ccc',
                      borderRadius: '4px',
                      color: 'black',
                    }}
                  />
                ) : item.type === 'table' ? (
                  <EditableTable />
                ) : item.type === 'image' ? (
                  <div style={{ position: 'relative' }}>
                    <img
                      src={item.src}
                      alt="Dropped"
                      style={{ width: item.width || '150px', height: 'auto' }}
                    />
                    <div
                      style={{
                        position: 'absolute',
                        bottom: 0,
                        right: 0,
                        width: '10px',
                        height: '10px',
                        backgroundColor: 'red',
                        cursor: 'nwse-resize'
                      }}
                      onMouseDown={(e) => {
                        e.preventDefault();
                        const startX = e.clientX;
                        const startWidth = item.width || 150;
                        const onMouseMove = (e: MouseEvent) => {
                          const newWidth = startWidth + (e.clientX - startX);
                          handleImageResize(index, newWidth);
                        };
                        const onMouseUp = () => {
                          document.removeEventListener('mousemove', onMouseMove);
                          document.removeEventListener('mouseup', onMouseUp);
                        };
                        document.addEventListener('mousemove', onMouseMove);
                        document.addEventListener('mouseup', onMouseUp);
                      }}
                    />
                  </div>
                ) : (
                  item.id
                )}
              </DraggableItem>
            ) : null}
          </div>
        ))}
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The full code should look like this:

import React, { useState, useEffect } from 'react';
import DraggableItem from './Draggable';
import EditableTable from './EditableTable';
type DropZoneProps = {
  onDrop: (id: string, value?: string, src?: string) => void;
};

type GridItem = {
  id: string;
  value?: string;
  type: 'text' | 'table' | 'image';
  src?: string;
  width?: number;
  height?: number;
};

const DropZone = ({ onDrop }: DropZoneProps) => {
  const [zoomLevel, setZoomLevel] = useState(1);
  const [gridSize, setGridSize] = useState(3);
  const [grid, setGrid] = useState<Array<GridItem | null>>(Array(3 * 3).fill(null));
  const [draggedItemIndex, setDraggedItemIndex] = useState<number | null>(null);

  useEffect(() => {
    console.log(`Grid size changed: ${gridSize}`);
    setGrid((prevGrid) => {
      const newGrid = Array(gridSize * gridSize).fill(null);
      prevGrid.forEach((item, index) => {
        if (item && index < newGrid.length) {
          newGrid[index] = item;
        }
      });
      return newGrid;
    });
  }, [gridSize]);

  const handleDrop = (e: React.DragEvent<HTMLDivElement>, index: number) => {
    e.preventDefault();
    const id = e.dataTransfer.getData('text/plain');
    const value = e.dataTransfer.getData('text/value');
    const src = e.dataTransfer.getData('text/src');
    const type = id.includes('table') ? 'table' : id.includes('image') ? 'image' : 'text';
    const newGrid = [...grid];
    if (draggedItemIndex !== null && draggedItemIndex !== index) {
      newGrid[draggedItemIndex] = null;
      newGrid[index] = { id, value, type, src };
      setDraggedItemIndex(null);
    } else {
      newGrid[index] = { id, value, type, src };
    }
    setGrid(newGrid);
    onDrop(id, value, src);
  };

  const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
  };

  const handleDragStart = (index: number) => {
    setDraggedItemIndex(index);
  };

  const handleZoomIn = () => {
    console.log("handleZoomIn");
    const newSize = Math.max(1, Math.floor(gridSize / 1.1));
    console.log(`New grid size on zoom in: ${newSize}`);
    setZoomLevel(prevZoomLevel => prevZoomLevel + 0.1);
    setGridSize(newSize);
  };

  const handleZoomOut = () => {
    console.log("handleZoomOut");
    const newSize = Math.max(1, gridSize + 1);
    console.log(`New grid size on zoom out: ${newSize}`);
    setZoomLevel(prevZoomLevel => prevZoomLevel - 0.1);
    setGridSize(newSize);
  };

  const handleTextChange = (index: number, value: string) => {
    const newGrid = [...grid];
    if (newGrid[index]) {
      newGrid[index]!.value = value;
      setGrid(newGrid);
    }
  };

  const handleImageResize = (index: number, newWidth: number) => {
    const newGrid = [...grid];
    if (newGrid[index] && newGrid[index]!.type === 'image') {
      newGrid[index] = {
        ...newGrid[index]!,
        width: newWidth
      };
      setGrid(newGrid);
    }
  };

  return (
    <div>
      <div className="zoom-controls">
        <button onClick={handleZoomIn}>Zoom In</button>
        <button onClick={handleZoomOut}>Zoom Out</button>
      </div>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: `repeat(${gridSize}, 1fr)`,
          border: '2px dashed #ccc',
        }}
        className='w-full h-screen overflow-y-auto overflow-x-auto'
      >
        {grid.map((item, index) => (
          <div
            key={index}
            onDrop={(e) => handleDrop(e, index)}
            onDragOver={handleDragOver}
            style={{
              width: '100%',
              height: '100%',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              backgroundColor: item ? '#e0e0e0' : 'transparent',
              border: '1px solid #ccc',
              position: 'relative'
            }}
          >
            {item ? (
              <DraggableItem id={item.id} onDragStart={() => handleDragStart(index)}>
                {item.type === 'text' ? (
                  <textarea
                    value={item.value}
                    onChange={(e) => handleTextChange(index, e.target.value)}
                    placeholder='Start Typing...'
                    style={{
                      padding: '10px',
                      margin: '5px',
                      border: '1px solid #ccc',
                      borderRadius: '4px',
                      color: 'black',
                    }}
                  />
                ) : item.type === 'table' ? (
                  <EditableTable />
                ) : item.type === 'image' ? (
                  <div style={{ position: 'relative' }}>
                    <img
                      src={item.src}
                      alt="Dropped"
                      style={{ width: item.width || '150px', height: 'auto' }}
                    />
                    <div
                      style={{
                        position: 'absolute',
                        bottom: 0,
                        right: 0,
                        width: '10px',
                        height: '10px',
                        backgroundColor: 'red',
                        cursor: 'nwse-resize'
                      }}
                      onMouseDown={(e) => {
                        e.preventDefault();
                        const startX = e.clientX;
                        const startWidth = item.width || 150;
                        const onMouseMove = (e: MouseEvent) => {
                          const newWidth = startWidth + (e.clientX - startX);
                          handleImageResize(index, newWidth);
                        };
                        const onMouseUp = () => {
                          document.removeEventListener('mousemove', onMouseMove);
                          document.removeEventListener('mouseup', onMouseUp);
                        };
                        document.addEventListener('mousemove', onMouseMove);
                        document.addEventListener('mouseup', onMouseUp);
                      }}
                    />
                  </div>
                ) : (
                  item.id
                )}
              </DraggableItem>
            ) : null}
          </div>
        ))}
      </div>
    </div>
  );
};

export default DropZone;
Enter fullscreen mode Exit fullscreen mode

Changing Our Page.tsx Code

In the src/app/page.tsx file, we’ll need to change some things so that it can work with our DropZone.tsx component. We would need to adjust our code to handle different item types. We can do that with this code:

'use client';
import { useState } from 'react';
import DraggableItem from './components/Draggable';
import DropZone from './components/DropZone';
import EditableTable from './components/EditableTable';

type ItemType = {
  type: 'textbox' | 'item' | 'table' | 'image';
  id: string;
  value?: string;
  src?: string;
};

const Home = () => {
  const [items, setItems] = useState<ItemType[]>([
    { type: 'textbox', value: '', id: 'textbox1' },
    { type: 'table', id: 'table1' },
    { type: 'image', id: 'image1', src: 'https://via.placeholder.com/150' },
  ]);
  const [droppedItems, setDroppedItems] = useState<ItemType[]>([]);
  const [draggedItemIndex, setDraggedItemIndex] = useState<number | null>(null);

  const handleDragStart = (index: number) => {
    setDraggedItemIndex(index);
  };

  const handleDrop = (id: string, value?: string, src?: string) => {
    if (draggedItemIndex !== null) {
      const draggedItem = items[draggedItemIndex];
      setDroppedItems([...droppedItems, draggedItem]);
      setItems(items.map((item, index) =>
        index === draggedItemIndex && item.type !== 'textbox' && item.type !== 'table' && item.type !== 'image'
          ? { ...item, id: `${item.id.split('-')[0]}-${Date.now()}` }
          : item
      ));
    }
  };

  return (
    <main>
      <h1>Drag and Drop Example</h1>
      <div style={{ display: 'flex', flexDirection: 'row' }}>
        {items.map((item, index) => (
          <DraggableItem key={item.id} id={item.id} onDragStart={() => handleDragStart(index)}>
            {item.type === 'textbox' ? (
              item.id
            ) : item.type === 'table' ? (
              item.id
            ) : item.type === 'image' ? (
              item.id
            ) : (
              item.id
            )}
          </DraggableItem>
        ))}
      </div>
      <DropZone onDrop={(id, value, src) => handleDrop(id, value, src)} />
      <div>
        <h2>Dropped Items:</h2>
        {droppedItems.map((item, index) => (
          <div key={index} className='text-black'>
            {item.type === 'textbox' ? (
              <textarea
                value={item.value}
                readOnly
                placeholder='Start Typing...'
                style={{
                  padding: '10px',
                  margin: '5px',
                  border: '1px solid #ccc',
                  borderRadius: '4px',
                  color: 'black',
                }}
              />
            ) : item.type === 'table' ? (
              <EditableTable />
            ) : item.type === 'image' ? (
              <img
                src={item.src}
                alt="Dropped"
                style={{ width: '150px', height: 'auto' }}
              />
            ) : (
              item.id
            )}
          </div>
        ))}
      </div>
    </main>
  );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

The Table Component

You might be wondering, what about the new EditableTable.tsx component? Well, this is the code for that:

import React, { useState } from 'react';

const EditableTable = () => {
  const [tableData, setTableData] = useState([
    ['', ''],
    ['', '']
  ]);

  const handleTableChange = (rowIndex: number, colIndex: number, value: string) => {
    const newTableData = tableData.map((row, rIndex) =>
      row.map((cell, cIndex) => (rIndex === rowIndex && cIndex === colIndex ? value : cell))
    );
    setTableData(newTableData);
  };

  const addRow = () => {
    setTableData([...tableData, Array(tableData[0].length).fill('')]);
  };

  const addColumn = () => {
    setTableData(tableData.map(row => [...row, '']));
  };

  return (
    <div>
      <table>
        <tbody>
          {tableData.map((row, rowIndex) => (
            <tr key={rowIndex}>
              {row.map((cell, colIndex) => (
                <td key={colIndex}>
                  <input
                    type="text"
                    value={cell}
                    onChange={(e) => handleTableChange(rowIndex, colIndex, e.target.value)}
                  />
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      <button onClick={addRow}>Add Row</button>
      <button onClick={addColumn}>Add Column</button>
    </div>
  );
};

export default EditableTable;
Enter fullscreen mode Exit fullscreen mode

That wraps up this article on how to add editable draggable items to your existing drag-and-drop functionality in Next.js. If you liked this article, follow me on Medium and subscribe to my newsletter.

Happy Coding Folks!

Top comments (0)