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!
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.tsx
code 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;
};
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;
};
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);
};
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);
}
};
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>
);
};
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;
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;
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;
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)