Building a Shared Real-Time Drawing Application with React and Konva

Estimated read time 18 min read

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:

  1. Real-Time Drawing: Synchronize drawing actions across multiple clients.
  2. Cursor Tracking: Display real-time cursor positions and usernames.
  3. Drawing Clearing: Clear all drawings with a single button.
  4. Error Handling: Graceful handling of network errors and input issues.

Prerequisites

Before you start, make sure you have:

  1. Node.js: Install from nodejs.org. It includes npm (Node Package Manager).
  2. npm: Check installation with npm --version in your terminal.
  3. Basic Understanding of React: Familiarity with components, state, and props. See the React documentation for details.
  4. Basic Understanding of JavaScript: Knowledge of JavaScript, including ES6 features. Refer to MDN Web Docs for a refresher.
  5. 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 and setLines:
    • 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 the lines state.
  • currentLine and setCurrentLine:
    • 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 and setCursors:
    • cursors is an object that keeps track of the positions of all user cursors on the whiteboard. Each cursor is identified by a unique userId.
    • 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.
  • userName:
    • This assigns a unique username to each user, based on the userId. The format is "User-[userId]".
  • stageRef:
    • stageRef is a reference to the Konva Stage 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.
  • 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.
  • Handling Cursor Events:
    • data.type === 'cursor':
      • Updates the cursors state with the new cursor position and username for the user who moved their cursor.
    • data.type === 'cursor-hide':
      • Removes the cursor from the cursors state when a user leaves the drawing area.
  • Handling Drawing Events:
    • data.type === 'drawing':
      • Appends a new line to the lines state, ensuring the drawing is updated across all clients.
    • 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 the currentLine 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 updates currentLine by adding the new mouse position to it.
      • The updated line is then sent to the server using a POST request. The request includes a type of drawing-move to indicate an ongoing drawing action.
    • 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.
  • 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 the lines state and clearing currentLine to indicate that the drawing has ended.
      • The final line is then sent to the server with a type of drawing-end to signal the completion of this drawing action.
  • Server Synchronization:
    • POST Requests:
      • Each drawing action (drawing-move or drawing-end) is sent to the server using a POST request to ensure that all connected clients receive the updates and synchronize their view of the shared whiteboard.
    • Error Handling:
      • Each fetch call includes error handling with .catch, ensuring that any issues during communication with the server are logged for debugging.

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 setting lines 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.
    • 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));.

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 from react-konva acts as the root container for the canvas, where all drawing and cursor elements are rendered.
    • Attributes:
      • width and height are set to match the browser window dimensions, making the canvas fill the entire screen.
      • Event handlers like onMouseDown, onMouseMove, onMouseUp, and onMouseLeave 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.
  • <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.
  • 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.
    • <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" and lineJoin="round" ensure smooth line endings and corners.
  • 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.
    • <Circle> Component:
      • Attributes:
        • x={cursor.x} and y={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.
    • <Text> Component:
      • Purpose: Displays the username next to the cursor.
      • Attributes:
        • x={cursor.x + 10} and y={cursor.y - 10}: Position the text slightly offset from the cursor.
        • text={cursor.userName}: Displays the username.
        • fontSize={12} and fill="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.
  • 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 the cors middleware, the server can allow or restrict access from various origins.
  • const http = require('http');:
    • Purpose: This line imports Node.js’s built-in http module. Although Express 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).
  • 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.
  • 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.
  • const server = http.createServer(app);:
    • Purpose: This line creates an HTTP server using the http module and the Express app instance. The server 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.

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 the drawings array.
    • This ensures that any new clients joining the session receive the current state of the drawing board.

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 or drawing-end).
    • If the data is valid, it is added to the drawings array, and the server responds with a 201 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.

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.

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 the cursors 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.

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.

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!

Dimitrios S. Sfyris https://aspectsoft.gr/en/

Dimitrios S. Sfyris is a leading expert in systems engineering and web
architectures. With years of experience in both academia and industry, he has published numerous articles and research papers. He is the founder of AspectSoft, a company that developed the innovative e-commerce platform AspectCart, designed to revolutionize the way businesses operate in the e-commerce landscape. He also created the Expo-Host platform for 3D interactive environments.

https://www.linkedin.com/in/dimitrios-s-sfyris/

You May Also Like

More From Author

+ There are no comments

Add yours