Logo

dev-resources.site

for different kinds of informations.

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

Published at
9/16/2024
Categories
nextjs
approuter
typescript
react
Author
nifty-little-me
Categories
4 categories in total
nextjs
open
approuter
open
typescript
open
react
open
Author
15 person written this
nifty-little-me
open
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!

approuter Article's
23 articles in total
Favicon
Show a loading screen when changing pages in Next.js App router
Favicon
Learning Next.js 13 App Router: A Comprehensive Guide ๐Ÿš€
Favicon
Guide to build a modern Web App using Next.js 14 (App Router), Fully authentication (NextAuth), Theming and i18n
Favicon
A practical Guide - Migrating to Next.js App Router
Favicon
Adding Chat Functionality To Your Next.Js Project With Firebase
Favicon
Spicing Up Your Next.Js Projects With 3D: What Are Your Options?
Favicon
No More Pages
Favicon
How To Create A Basic Infinity Canvas For Your Next.Js Project
Favicon
How To Implement Text-To-Speech Functionality For BlockNote In Next.Js
Favicon
How To Add Drag-And-Drop Functionality With Editable Draggable Items In Next.js
Favicon
Adding Drag And Drop Functionality In Your Next.Js Project Without A Library
Favicon
Creating NPM Packages in Next.Js for Next.Js
Favicon
Using Firebase To Store Folders and BlockNote Documents In Next.Js
Favicon
An Alternative To Editor.js: BlockNote For React
Favicon
How To Add Editor.Js To Your Next.Js Code
Favicon
How To Use The Quill Rich Text Editor in Your Next.Js App Router Project
Favicon
Simple NextJS GraphQL client
Favicon
Implementing Internationalization (i18n) in Next.js 14 using App Router
Favicon
Web Streams API in Action: Delivering 6000+ Log Lines Concurrently Across 20 Tabs
Favicon
How to use React-Toastify with Next.js App router
Favicon
NextAuth - Implementando "Custom Login Pages" com "Credentials Sign in" utilizando App Router
Favicon
The origin of App Router - A Next.Js Rewind
Favicon
How to add multiple routers in a node application without using app.use() for each router ?

Featured ones: