In this tutorial, we will build a shared real-time drawing application using React and Konva. This app will allow multiple users to draw on the same canvas in real time, track cursor positions, and clear drawings synchronously. We will break down the code into manageable sections for easier understanding.
Why Choose React and Konva?
React is a popular JavaScript library for building user interfaces, while Konva is a powerful canvas library that simplifies drawing operations. Together, they provide a robust framework for creating interactive and responsive applications.
Key Features of Our Whiteboard Application:
- Real-Time Drawing: Synchronize drawing actions across multiple clients.
- Cursor Tracking: Display real-time cursor positions and usernames.
- Drawing Clearing: Clear all drawings with a single button.
- Error Handling: Graceful handling of network errors and input issues.
Prerequisites
Before you start, make sure you have:
- Node.js: Install from nodejs.org. It includes npm (Node Package Manager).
- npm: Check installation with
npm --version
in your terminal. - Basic Understanding of React: Familiarity with components, state, and props. See the React documentation for details.
- Basic Understanding of JavaScript: Knowledge of JavaScript, including ES6 features. Refer to MDN Web Docs for a refresher.
- Text Editor or IDE: Use tools like Visual Studio Code, Sublime Text, or Atom.
Step 1: Set Up Your React Project
First, create a new React project and install the necessary dependencies:
npx create-react-app shared-drawing
cd shared-drawing
npm install react-konva konva
Step 2: Create the Drawing Component
2.1 Initialize State and Refs
Create a file named Drawing.js
in the src
directory. Start by setting up state variables and references:
import React, { useState, useRef, useEffect } from 'react';
import { Stage, Layer, Line, Circle, Text } from 'react-konva';
import debounce from 'lodash/debounce';
const Drawing = () => {
const [lines, setLines] = useState([]);
const [currentLine, setCurrentLine] = useState([]);
const [cursors, setCursors] = useState({});
const [userId] = useState(Date.now());
const [userName] = useState(`User-${userId}`);
const [isDrawing, setIsDrawing] = useState(false);
const stageRef = useRef(null);
...
}
export default Whiteboard;
Breakdown:
lines
andsetLines
:lines
is an array that stores all the drawn lines on the whiteboard. Each line is an array of points (x, y coordinates).setLines
is a function that updates thelines
state.
currentLine
andsetCurrentLine
:currentLine
holds the points of the line currently being drawn by the user.setCurrentLine
is used to update this state as the user draws.
cursors
andsetCursors
:cursors
is an object that keeps track of the positions of all user cursors on the whiteboard. Each cursor is identified by a uniqueuserId
.setCursors
updates the cursor positions in the state.
userId
:- This is a unique identifier for each user, generated using
Date.now()
. It’s used to distinguish between different users in the application.
- This is a unique identifier for each user, generated using
userName
:- This assigns a unique username to each user, based on the
userId
. The format is"User-[userId]"
.
- This assigns a unique username to each user, based on the
stageRef
:stageRef
is a reference to the KonvaStage
element. It allows us to directly interact with the drawing area, like getting the current pointer position.
These state variables and refs play a key role in managing drawing, user interactions, and cursor movements in real-time.
2.2 Handle Real-Time Updates
Set up EventSource
to listen for real-time updates from the server:
useEffect(() => {
const eventSource = new EventSource('http://YOUR_SERVER_IP:3001/events');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'cursor':
setCursors(prevCursors => ({
...prevCursors,
[data.userId]: { x: data.payload.x, y: data.payload.y, userName: data.payload.userName }
}));
break;
case 'cursor-hide':
setCursors(prevCursors => {
const updatedCursors = { ...prevCursors };
delete updatedCursors[data.userId];
return updatedCursors;
});
break;
case 'drawing':
setLines(prevLines => [...prevLines, data.payload]);
break;
case 'initial':
setLines(data.drawings);
setCursors(data.cursors);
break;
case 'clear':
setLines([]);
break;
default:
console.error('Unknown event type:', data.type);
}
};
eventSource.onerror = (error) => {
console.error('EventSource failed:', error);
};
return () => {
eventSource.close();
};
}, []);
Breakdown:
useEffect
Hook:- This hook runs once when the component mounts and sets up the real-time event handling. It’s crucial for establishing the connection with the server and listening for updates.
const eventSource = new EventSource('http://YOUR_SERVER_IP:3001/events');
:- This line creates a new
EventSource
instance that connects to the server at the specified URL. The server uses Server-Sent Events (SSE) to push updates (like new drawings or cursor movements) to the client in real-time.
- This line creates a new
eventSource.onmessage
:- This function handles incoming messages (events) from the server. The server sends different types of events (
cursor
,cursor-hide
,drawing
,initial
), and we handle each type accordingly.
- This function handles incoming messages (events) from the server. The server sends different types of events (
- Handling Cursor Events:
data.type === 'cursor'
:- Updates the
cursors
state with the new cursor position and username for the user who moved their cursor.
- Updates the
data.type === 'cursor-hide'
:- Removes the cursor from the
cursors
state when a user leaves the drawing area.
- Removes the cursor from the
- Handling Drawing Events:
data.type === 'drawing'
:- Appends a new line to the
lines
state, ensuring the drawing is updated across all clients.
- Appends a new line to the
data.type === 'initial'
:- On a new connection, it loads the existing drawings and cursor positions so the user is up to date with the current state of the whiteboard.
- Error Handling:
eventSource.onerror
:- Logs any errors that occur with the SSE connection, helping with debugging and ensuring the application handles connection issues gracefully.
- Cleanup:
return () => { eventSource.close(); };
:- This cleans up the event source connection when the component unmounts, preventing memory leaks and ensuring the connection is properly closed.
This code is crucial for keeping drawing and cursor movements synchronized in real-time across all users connected to the application.
2.3 Handle Drawing Events
Add functions to handle drawing operations:
const handleMouseDown = (e) => {
const pos = stageRef.current.getPointerPosition();
setCurrentLine([pos.x, pos.y]);
setIsDrawing(true);
};
const handleMouseMove = (e) => {
if (!isDrawing) return;
const pos = stageRef.current.getPointerPosition();
const newLine = [...currentLine, pos.x, pos.y];
setCurrentLine(newLine);
fetch('http://YOUR_SERVER_IP:3001/drawings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'drawing-move', points: newLine }),
})
.then(response => response.json())
.then(data => console.log('Drawing move posted:', data))
.catch(error => console.error('Error posting drawing move:', error));
debouncedCursorUpdate(pos.x, pos.y);
};
const handleMouseUp = () => {
if (!isDrawing) return;
const newLine = currentLine;
setLines(prevLines => [...prevLines, { points: newLine }]);
setCurrentLine([]);
setIsDrawing(false);
fetch('http://YOUR_SERVER_IP:3001/drawings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'drawing-end', points: newLine }),
})
.then(response => response.json())
.then(data => console.log('Drawing end posted:', data))
.catch(error => console.error('Error posting drawing end:', error));
fetch('http://YOUR_SERVER_IP:3001/cursor-hide', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }),
})
.then(response => response.text())
.then(text => console.log('Cursor hide posted:', text))
.catch(error => console.error('Error posting cursor hide:', error));
};
Breakdown:
handleMouseDown
:- Function: This function is triggered when the user presses the mouse button down on the canvas.
- Position Initialization: It captures the current position of the mouse on the stage using
stageRef.current.getPointerPosition()
and initializes thecurrentLine
state with the starting coordinates.
handleMouseMove
:- Function: This function is called whenever the mouse moves while the button is held down.
- Drawing Update:
- If a line is currently being drawn (
currentLine
is not empty), the function updatescurrentLine
by adding the new mouse position to it. - The updated line is then sent to the server using a
POST
request. The request includes atype
ofdrawing-move
to indicate an ongoing drawing action.
- If a line is currently being drawn (
- Cursor Synchronization:
- Simultaneously, it also updates the cursor position on the server so that other users see the cursor moving along with the drawing. The
POST
request for the cursor position includes the user ID and the new cursor coordinates.
- Simultaneously, it also updates the cursor position on the server so that other users see the cursor moving along with the drawing. The
handleMouseUp
:- Function: This function is called when the user releases the mouse button.
- Finalizing the Line:
- It checks if a line is currently being drawn. If so, the line is finalized by pushing the
currentLine
points to thelines
state and clearingcurrentLine
to indicate that the drawing has ended. - The final line is then sent to the server with a
type
ofdrawing-end
to signal the completion of this drawing action.
- It checks if a line is currently being drawn. If so, the line is finalized by pushing the
- Server Synchronization:
- POST Requests:
- Each drawing action (
drawing-move
ordrawing-end
) is sent to the server using aPOST
request to ensure that all connected clients receive the updates and synchronize their view of the shared whiteboard.
- Each drawing action (
- Error Handling:
- Each fetch call includes error handling with
.catch
, ensuring that any issues during communication with the server are logged for debugging.
- Each fetch call includes error handling with
- POST Requests:
This code captures drawing actions, updates them in real-time, and synchronizes them across all users. By handling start, update, and end actions separately, the code ensures an interactive and smooth drawing experience for multiple users.
2.4 Clear Drawing Function
Add a button to clear drawings and synchronize this action:
const handleClear = () => {
fetch('http://YOUR_SERVER_IP:3001/clear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
.then(response => response.json())
.then(data => console.log('Clear drawing posted:', data))
.catch(error => console.error('Error posting clear drawing:', error));
};
Breakdown:
handleClearDrawing
:- Function: This function is invoked when the user clicks a button to clear the drawing.
- Local State Clearing:
setLines([])
: The function first clears the local state by settinglines
to an empty array, which immediately removes all drawn lines from the user’s canvas.
- Server Notification:
- The function then sends a
POST
request to the server at the/clear
endpoint, notifying it that the drawing should be cleared for all connected users. - The
POST
request does not require any specific data to be sent in the body, as the act of making the request itself is the trigger for the server to clear the canvas.
- The function then sends a
- Response Handling:
- Once the request is successful, the response from the server is logged with
console.log
to confirm that the clearing action has been posted and processed. - If there’s an issue with the request, it is caught and logged using
.catch(error => console.error('Error posting clear drawing:', error));
.
- Once the request is successful, the response from the server is logged with
Button Implementation:
To trigger the handleClearDrawing
function, a button is added to the UI:
<button onClick={handleClearDrawing}>Clear Drawing</button>
Clicking this button calls handleClearDrawing
, which clears the canvas for all users.
Server Synchronization:
- Purpose: The
POST
request to/clear
ensures that when one user clears their canvas, this action is communicated to the server, which then broadcasts it to all other connected clients. - Effect: All users will see their canvas clear simultaneously, maintaining synchronization across all clients.
This implementation allows for a consistent and synchronized clearing of the canvas, ensuring that all users share the same view when the drawing is reset.
2.5 Render the Canvas
Finally, render the drawing canvas and user cursors:
return (
<div>
<button onClick={handleClear}>Clear Drawing</button>
<Stage
width={window.innerWidth}
height={window.innerHeight}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
ref={stageRef}
>
<Layer>
{lines.map((line, i) => (
<Line
key={i}
points={line.points}
stroke="black"
strokeWidth={2}
lineCap="round"
lineJoin="round"
/>
))}
{currentLine.length > 0 && (
<Line
points={currentLine}
stroke="black"
strokeWidth={2}
lineCap="round"
lineJoin="round"
/>
)}
{Object.keys(cursors).map((userId) => {
const cursor = cursors[userId];
return (
<React.Fragment key={userId}>
<Circle
x={cursor.x}
y={cursor.y}
radius={5}
fill="red"
/>
<Text
x={cursor.x + 10}
y={cursor.y - 10}
text={cursor.userName}
fontSize={12}
fill="black"
/>
</React.Fragment>
);
})}
</Layer>
</Stage>
</div>
);
Breakdown:
<Stage>
Component:- Purpose: The
<Stage>
component fromreact-konva
acts as the root container for the canvas, where all drawing and cursor elements are rendered. - Attributes:
width
andheight
are set to match the browser window dimensions, making the canvas fill the entire screen.- Event handlers like
onMouseDown
,onMouseMove
,onMouseUp
, andonMouseLeave
are attached to handle drawing and cursor updates. ref={stageRef}
attaches a reference to the<Stage>
element, which is useful for getting the cursor position and other operations.
- Purpose: The
<Layer>
Component:- Purpose: The
<Layer>
component serves as a container within the<Stage>
. It holds all the drawing lines and cursor elements, ensuring they are rendered together on the same canvas layer.
- Purpose: The
- Rendering Lines:
lines.map()
:- The
lines
array contains all completed lines drawn by the users. - For each line in the array, a
<Line>
component is rendered.
- The
<Line>
Component:- Attributes:
points={line.points}
: Defines the coordinates of the line.stroke="black"
: Sets the line color to black.strokeWidth={2}
: Sets the thickness of the line.lineCap="round"
andlineJoin="round"
ensure smooth line endings and corners.
- Attributes:
- Rendering Current Line:
currentLine.length > 0
:- This condition checks if there is an active line being drawn.
- If true, a
<Line>
component is rendered for the current line being drawn, updating in real-time as the user moves the cursor.
- Rendering Cursors:
Object.keys(cursors).map()
:- This iterates over the
cursors
object, which stores the cursor positions and usernames of all connected users.
- This iterates over the
<Circle>
Component:- Attributes:
x={cursor.x}
andy={cursor.y}
: Set the position of the cursor on the canvas.radius={5}
: Defines the size of the cursor.fill="red"
: Sets the cursor color to red, making it easily visible.
- Attributes:
<Text>
Component:- Purpose: Displays the username next to the cursor.
- Attributes:
x={cursor.x + 10}
andy={cursor.y - 10}
: Position the text slightly offset from the cursor.text={cursor.userName}
: Displays the username.fontSize={12}
andfill="black"
: Style the text with a small font size and black color.
This rendering logic keeps the canvas updated with both ongoing lines and the cursors of all users in real-time. The canvas immediately reflects each user’s drawing and cursor movements, creating a synchronized collaborative experience
Step 3: Set Up the Server
3.1 Server Initialization
Create a server.js
file to manage drawing data and real-time communication. Start with initialization to set up essential tools and configurations for handling client connections and enabling live interactions.
const express = require('express');
const cors = require('cors');
const http = require('http');
const app = express();
const port = 3001;
const server = http.createServer(app);
const corsOptions = {
origin: 'http://YOUR_SERVER_IP:3000',
methods: 'GET,POST',
allowedHeaders: 'Content-Type',
};
app.use(cors(corsOptions));
app.use(express.json());
let drawings = [];
let clients = [];
let cursors = {};
Breakdown:
const express = require('express');
:- Purpose: This line imports the
express
module, which is a popular web framework for Node.js.Express
simplifies the process of setting up a server and handling HTTP requests.
- Purpose: This line imports the
const cors = require('cors');
:- Purpose: This line imports the
cors
middleware. CORS (Cross-Origin Resource Sharing) is used to manage how resources on your server are accessed by web pages from different domains. By using thecors
middleware, the server can allow or restrict access from various origins.
- Purpose: This line imports the
const http = require('http');
:- Purpose: This line imports Node.js’s built-in
http
module. AlthoughExpress
can handle HTTP requests directly, this module is used here to create an HTTP server, which provides more flexibility for handling server-side operations, such as WebSocket connections or Server-Sent Events (SSE).
- Purpose: This line imports Node.js’s built-in
const app = express();
:- Purpose: This line creates an instance of an Express application. The
app
object is essential for defining routes, applying middleware, and handling HTTP requests.
- Purpose: This line creates an instance of an Express application. The
const port = 3001;
:- Purpose: This line defines the port on which the server will listen for incoming connections. Port
3001
is chosen for this application, but it can be configured to any available port.
- Purpose: This line defines the port on which the server will listen for incoming connections. Port
const server = http.createServer(app);
:- Purpose: This line creates an HTTP server using the
http
module and the Expressapp
instance. Theserver
object can handle low-level HTTP requests and is required when using features like Server-Sent Events (SSE) or WebSockets. This setup allows the Express app to run within an HTTP server, enabling real-time communication features for the shared drawing application.
- Purpose: This line creates an HTTP server using the
The server is now ready to be configured with routes and logic to handle drawing events, cursor movements, and more.
3.2 Handle Routes
Define routes for handling drawings, cursor updates, and clearing drawings:
app.get('/drawings', (req, res) => {
res.json(drawings);
});
app.post('/drawings', (req, res) => {
const drawing = req.body;
if (drawing.type === 'drawing-move' || drawing.type === 'drawing-end') {
drawings.push(drawing);
res.status(201).json(drawing);
clients.forEach(client => client.write(`data: ${JSON.stringify({ type: 'drawing', payload: drawing })}\n\n`));
} else {
res.status(400).json({ error: 'Invalid drawing data' });
}
});
app.post('/clear', (req, res) => {
drawings = [];
clients.forEach(client => client.write(`data: ${JSON.stringify({ type: 'clear' })}\n\n`));
res.status(200).json({ message: 'Drawings cleared' });
});
Breakdown:
1. Retrieve All Drawings
- Purpose: This route is designed to retrieve the full list of drawings from the server.
- Explanation:
- When a client requests the
/drawings
endpoint with a GET request, the server sends back a JSON response containing all the drawings stored in thedrawings
array. - This ensures that any new clients joining the session receive the current state of the drawing board.
- When a client requests the
2. Post a New Drawing
- Purpose: This route handles the submission of new drawing data to the server.
- Explanation:
- The server checks if the drawing data is valid by verifying its type (either
drawing-move
ordrawing-end
). - If the data is valid, it is added to the
drawings
array, and the server responds with a201 Created
status, including the drawing data in the response. - The server then broadcasts this new drawing data to all connected clients using Server-Sent Events (SSE).
- If the drawing data is invalid, the server responds with a
400 Bad Request
status and an error message.
- The server checks if the drawing data is valid by verifying its type (either
3. Clear All Drawings
- Purpose: This route provides the functionality to clear all drawings from the server.
- Explanation:
- The server clears the
drawings
array, removing all current drawings. - It then sends a
clear
event via SSE to all connected clients to instruct them to clear their drawing boards. - The server responds with a
200 OK
status and a confirmation message indicating that the drawings have been cleared.
- The server clears the
These routes handle key operations related to drawing management, ensuring that drawing data is correctly stored, synchronized among clients, and cleared when needed.
3.3 Server-Sent Events
Handle SSE connections and cursor updates:
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
clients.push(res);
res.write(`data: ${JSON.stringify({ type: 'initial', drawings, cursors })}\n\n`);
req.on('close', () => {
clients = clients.filter(client => client !== res);
});
});
app.post('/cursor', (req, res) => {
const { userId, userName, payload } = req.body;
cursors[userId] = { ...payload, userName };
clients.forEach(client => client.write(`data: ${JSON.stringify({ type: 'cursor', userId, payload: cursors[userId] })}\n\n`));
res.status(204).end();
});
app.post('/cursor-hide', (req, res) => {
const { userId } = req.body;
clients.forEach(client => client.write(`data: ${JSON.stringify({ type: 'cursor-hide', userId })}\n\n`));
res.status(204).end();
});
server.listen(port, () => {
console.log(`Server running on http://YOUR_SERVER_IP:${port}`);
});
Breakdown:
1. Initialize SSE Connection
- Purpose: This route sets up a Server-Sent Events (SSE) connection to push real-time updates to clients.
- Explanation:
- The server configures the response headers to indicate that it will be sending updates using SSE.
- It adds the client’s response object to the
clients
array, enabling the server to send updates to this client. - Initially, the server sends the current state of all drawings and cursor positions to the newly connected client.
- If the client disconnects, the server removes the client from the
clients
array to stop sending updates.
2. Update Cursor Position
- Purpose: This route handles updates to the cursor position and information of users.
- Explanation:
- When a client sends cursor position updates via a POST request to
/cursor
, the server updates thecursors
object with the new data. - The server then broadcasts this updated cursor information to all connected clients, allowing them to display the new cursor positions and associated usernames.
- When a client sends cursor position updates via a POST request to
3. Hide Cursor
- Purpose: This route handles requests to hide a user’s cursor when they leave the drawing area.
- Explanation:
- When a POST request to
/cursor-hide
is received, the server sends a message to all connected clients instructing them to hide the cursor for the specified user. - This ensures that cursors are hidden when users are not actively interacting with the drawing area.
- When a POST request to
These SSE routes are crucial for maintaining real-time synchronization of drawing and cursor data across all connected clients, ensuring a consistent collaborative experience.
Wrapping Up: Your Collaborative Canvas is Ready!
Congratulations! You’ve successfully built a Shared Real-time Drawing application that allows multiple users to collaborate on a whiteboard in real-time. By following this tutorial, you have:
- Set Up the React Frontend: Implemented features for drawing, cursor tracking, and synchronization across different users.
- Configured the Node.js Server: Managed real-time updates and handled server-sent events to keep all clients in sync.
- Implemented Key Features: Added functionality to handle drawing events, cursor movements, and clearing the canvas, ensuring a seamless collaborative experience.
With this foundation, you can further enhance your application by adding features such as user authentication, more advanced drawing tools, or chat functionality. Continue exploring and experimenting to make your collaborative drawing tool even more powerful!
Thank you for following along, and best of luck with your future projects!
Sponsored Links
Written by Dimitrios S. Sfyris, founder and developer of AspectSoft, a software company specializing in innovative solutions. Follow me on LinkedIn for more insightful articles and updates on cutting-edge technologies.
Subscribe to our newsletter!
+ There are no comments
Add yours