Collaboration In Real Time With React And Node

Collaboration In Real Time With React And Node

ยท

19 min read

by King AJ

In today's rapidly evolving digital environment, real-time collaboration has become a paramount feature in various web applications. The ability to work together seamlessly, irrespective of geographical boundaries, has transformed how teams collaborate and communicate. This article will show how to build a web app providing a real time collaboration board using React and Node.


Session Replay for Developers

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay โ€” an open-source session replay suite for developers. It can be self-hosted in minutes, giving you complete control over your customer data.

OpenReplay

Happy debugging! Try using OpenReplay today.


Real-time collaboration involves multiple users interacting dynamically and instantly while collaborating on a shared task or project. Unlike older collaboration methods with communication delays and updates, real-time collaboration lets team members contribute, edit, and see changes in the moment. This instant syncing promotes a feeling of togetherness and speeds up decision-making, making it a vital tool for contemporary workflows. Incorporating real-time collaboration features brings a host of benefits:

  • Global Reach: Real-time collaboration allows remote teams to collaborate as if they were in the same physical location. This feature is particularly valuable in an era where remote work has become the norm, enabling teams spread across the globe to collaborate seamlessly.

  • Brainstorm Together: Real-time collaboration sparks group brainstorming and idea-sharing, letting everyone collectively tackle challenges and create fresh solutions.

  • Efficiency Boost: Real-time collaboration eliminates communication delays, letting team members engage and contribute on the spot. This speeds up solving problems and streamlines workflows. There are different forms of real-time collaboration, such as: Document Collaboration, Video Conferencing, Instant Messaging and Chat, Project Management Tools, Co-browsing, Shared Calendars, Interactive Presentations, Social Media Collaboration, and Multiplayer Gaming. For this article, we'll be focusing on Real-time Whiteboarding. A Real-time whiteboard is a dynamic digital tool replicating a traditional whiteboard's functionality in an online environment. It provides individuals and teams with a shared canvas where they can collaborate in real-time, allowing for the creation, manipulation, and visualization of ideas, concepts, and information.

Our project

Using React and Node.js, we will delve into the exciting realm of real-time collaboration by building a real-time collaborating board using React and Node.js. Our project will enable users to work on a shared virtual board in real time, instantly updating the content and changes for all participants. We will incorporate the drag-and-drop feature so users can effortlessly move and arrange elements on the board, making collaboration even more intuitive and engaging. Whether you want to build a collaborative tool for remote teams, an educational platform, a project management application, or any other project demanding real-time collaboration, this article will equip you with the essential skills and knowledge to develop interactive and efficient real-time applications. So, letโ€™s dive in and unlock the immense potential of real-time collaboration with React and Node.js. To set up our React app for this project, we'll do the following:

  • Create React Application: Navigate to your desired directory, open your terminal, and run the following command to create a new React application using create-react-app:
npx create-react-app collaborate_client
  • Navigating into the Project Directory: To move into the newly created project directory;
cd collaborate_client

Installing Dependencies

With our project initialized, it's time to install the necessary dependencies that will power our real-time collaborating board. These dependencies include socket.io for real-time communication and RoughJS for drawing capabilities.

  • socket.io: Install the socket.io library to establish WebSocket connections for real-time data exchange;
npm install `socket.io`
  • RoughJS: Integrate the rough.js library to enable drawing functionality on the collaborating board;
npm install --save roughjs

Creating the Collaborating Board User Interface with React

The' Canvas' component lies the heart of our real-time collaborating board. The Canvas is an HTML element that serves as a blank slate where we can paint, draw, and manipulate graphical elements using JavaScript. For the user interface, we'll create a WhiteBoard component where users can manipulate graphical elements in our React application.

Building a Canvas Component using React

Before delving into RoughJS and drawing capabilities, let's set the stage by creating our WhiteBoard component. In your React project, navigate to the appropriate directory and create a new file named Whiteboard.js. Inside this file, define your WhiteBoard component:

import React, { useLayoutEffect } from "react";
const WhiteBoard = () => {
 useLayoutEffect(() => {
  const canvas = document.getElementById("canvas");
  const ctx = canvas.getContext("2d");
  // Set canvas dimensions and initial styles
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  ctx.strokeStyle = "black";
  ctx.lineWidth = 5;
  // Implement drawing functionality here
 }, []);
 //implement event listeners for drawing interactions
 return (
  <>
   <canvas
    id="canvas"
    width={window.innerWidth}
    height={window.innerHeight}
   ></canvas>
  </>
 );
};
export default WhiteBoard;

In the above code, we import the necessary dependencies, create a WhiteBoard functional component, and utilize the useLayoutEffect hooks provided by React. Within the useLayoutEffect hook, we access the canvas element and its 2D rendering context to configure its dimensions and initial styles.

Integrating RoughJS within the Canvas Component

RoughJS is a lightweight library that enables us to create hand-drawn, sketch-like graphics within our canvas. By integrating RoughJS, we can transform our plain whiteboard into a playground of creativity, where lines, shapes, and textures come to life with an organic, handcrafted feel. To augment our WhiteBoard component with the RoughJS within the component file, update the code as follows:

import React, { useLayoutEffect } from "react";
import rough from "roughjs";
const WhiteBoard = () => {
 useLayoutEffect(() => {
  const canvas = document.getElementById("canvas");
  const ctx = canvas.getContext("2d");
  // Initialize RoughJS instance
  const roughCanvas = rough.canvas(canvas);
  // Set canvas initial styles
  ctx.strokeStyle = "black";
  ctx.lineWidth = 5;
  // Add event listeners for drawing interactions
  // Implement drawing functionality here
 }, []);
 return (
  <>
   <canvas
    id="canvas"
    width={window.innerWidth}
    height={window.innerHeight}
   ></canvas>
  </>
 );
};
export default WhiteBoard;

In the updated code, we've imported the RoughJS library and created an instance using the rough.canvas() method, associating it with our canvas element. This instance, stored in the roughCanvas, will allow us to apply RoughJS primitives and effects with which we can draw on the whiteboard. With RoughJS, there are endless possibilities for shapes, lines, and shades we can draw. For this article, we'll look at how to draw lines and rectangles on our Whiteboard. You can build on this knowledge and add other shapes and features RoughJS supports.

Drawing lines on our Canvas

To draw lines on our canvas using RoughJS we'll do the following;

  • Initialize Drawing State: Begin by setting up the necessary state to track drawing interactions:
const [drawing, setDrawing] = useState(false);
const [elements, setElements] = useState([]);
  • Handle Mouse Down Event: When a user initiates drawing by pressing the mouse button, we'll set the drawing state to true and create a new element. In the handleMouseDown function, we're utilizing the initial clientX and clientY values to mark the starting point of our drawing. When the user clicks the mouse, we want to record where that click occurred, as it will be the start of the line they're about to draw:
const handleMouseDown = (e) => {
 setDrawing(true);
 const { clientX, clientY } = e;
 // Create a new line element with the same start and end points
 const element = createElement(clientX, clientY, clientX, clientY);
 setElements((prevState) => [...prevState, element]);
};
  • Handle Mouse Move Event: With the mouse button still down, we continuously update the element we created in handleMouseDown with the current path of the mouse as the user moves the mouse on the canvas:
const handleMouseMove = (e) => {
 if (!drawing) return;
 const { clientX, clientY } = e;
 const index = elements.length - 1;
 const { x1, y1 } = elements[index];
 // Update the end point of the element and create an element
 const updatedElement = createElement(x1, y1, clientX, clientY);
 const elementsCopy = [...elements];
 elementsCopy[index] = updatedElement;
 setElements(elementsCopy);
};
  • Creating our line: The mouse coordinates will be sent to the createElement function, which then utilizes the RoughJS library to generate a hand-drawn representation of the element. This function then returns the coordinates and the RoughJS element, which will be stored in our elements state:
const createElement = (x1, y1, x2, y2) => {
 // Use the RoughJS generator to create a rough element (line or rectangle)
 const roughElement = generator.line(x1, y1, x2, y2);
 // Return an object representing the element, including its coordinates and RoughJS representation
 return { x1, y1, x2, y2, roughElement };
};
  • Rendering our elements: Using our useLayoutEffect function with every update to the elements state we render the elements stored in the state.
useLayoutEffect(() => {
 // Get the canvas element by its ID
 const canvas = document.getElementById("canvas");
 // Get the 2D rendering context of the canvas
 const ctx = canvas.getContext("2d");
 // Create a RoughJS canvas instance associated with the canvas element
 const roughCanvas = rough.canvas(canvas);
 // Set stroke style and line width for the canvas context
 ctx.strokeStyle = "black";
 ctx.lineWidth = 5;
 // Clear the entire canvas to ensure a clean drawing surface
 ctx.clearRect(0, 0, canvas.width, canvas.height);
 // If there are saved elements to render
 if (elements && elements.length > 0) {
  // Iterate through each saved element
  elements.forEach(({ roughElement }) => {
   // Use RoughJS to draw the element on the canvas
   roughCanvas.draw(roughElement);
  });
 }
}, [elements]); // This effect depends on the 'elements' state; re-run when it changes
  • Handle Mouse Up Event: When the user releases the mouse button, we set the drawing state to false, halting the drawing process;
const handleMouseUp = (e) => {
 setDrawing(false);
};

By implementing these steps, users can draw lines by clicking and dragging the mouse cursor on the canvas. Here's the WhiteBoard component with the ability to draw lines on our canvas;

import React, { useState, useLayoutEffect } from "react";
import rough from "roughjs/bundled/rough.esm.js";
// Create a RoughJS generator instance
const generator = rough.generator();
const WhiteBoard = () => {
 // State for managing drawing elements and interactions
 const [elements, setElements] = useState([]);
 const [drawing, setDrawing] = useState(false);
 // UseLayoutEffect: Responsible for rendering drawing elements
 useLayoutEffect(() => {
  // Get the canvas element by its ID
  const canvas = document.getElementById("canvas");
  // Get the 2D rendering context of the canvas
  const ctx = canvas.getContext("2d");
  // Create a RoughJS canvas instance associated with the canvas element
  const roughCanvas = rough.canvas(canvas);
  // Set stroke style and line width for the canvas context
  ctx.strokeStyle = "black";
  ctx.lineWidth = 5;
  // Clear the entire canvas to ensure a clean drawing surface
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // If there are saved elements to render
  if (elements && elements.length > 0) {
   // Iterate through each saved element
   elements.forEach(({ roughElement }) => {
    // Use RoughJS to draw the element on the canvas
    roughCanvas.draw(roughElement);
   });
  }
 }, [elements]);
 // Function to create a new drawing element
 const createElement = (x1, y1, x2, y2) => {
  // Use the RoughJS generator to create a rough element (line or rectangle)
  const roughElement = generator.line(x1, y1, x2, y2);
  // Return an object representing the element, including its coordinates and RoughJS representation
  return { x1, y1, x2, y2, roughElement };
 };
 // Event handler for mouse down
 const handleMouseDown = (e) => {
  setDrawing(true);
  const { clientX, clientY } = e;
  // Create a new drawing element when mouse down is detected
  const element = createElement(clientX, clientY, clientX, clientY);
  setElements((prevState) => [...prevState, element]);
 };
 // Event handler for mouse move
 const handleMouseMove = (e) => {
  if (!drawing) return;
  const { clientX, clientY } = e;
  // Find the index of the last element created during mouse down
  const index = elements.length - 1;
  const { x1, y1 } = elements[index];
  // Update the element's coordinates for dynamic drawing
  const UpdatedElement = createElement(x1, y1, clientX, clientY);
  const elementsCopy = [...elements];
  elementsCopy[index] = UpdatedElement;
  setElements(elementsCopy);
 };
 // Event handler for mouse up
 const handleMouseUp = () => {
  setDrawing(false);
 };
 // Return JSX to render the collaborative canvas
 return (
  <>
   <canvas
    id="canvas"
    onMouseDown={handleMouseDown}
    onMouseUp={handleMouseUp}
    onMouseMove={handleMouseMove}
    width={window.innerWidth}
    height={window.innerHeight}
   ></canvas>
  </>
 );
};
export default WhiteBoard;

Let's test our application:

-

The video above shows that our code works and draws lines on our whiteboard using our mouse coordinates.

Drawing Rectangles lines on our Canvas

The process for drawing rectangles on our whiteboard is almost the same as creating lines but only changes when we get to the createElement function. Before we see the update to our createElement function, let's create a state that stores the current tool the user intends to utilize.

const [tool, setTool] = useState('line');

By default, the tool is set to line on our canvas. Now we can update our createElement function to accommodate rectangles.

const createElement = (x1, y1, x2, y2) => {
 let roughElement;
 // Use the RoughJS generator to create a rough element (line or rectangle)
 if (tool === "line") {
  roughElement = generator.line(x1, y1, x2, y2);
 } else if (tool === "rect") {
  roughElement = generator.rectangle(x1, y1, x2 - x1, y2 - y1);
 }
 // Return an object representing the element, including its coordinates and RoughJS representation
 return { x1, y1, x2, y2, roughElement };
};

Now, we need to add buttons to allow the users to select what tool they want to use on our canvas.

return (
 <>
  <div className="d-flex col-md-2 justify-content-center gap-1">
   <div className="d-flex gap-1 align-items-center">
    <label htmlFor="line">Line</label>
    <input
     type="radio"
     id="line"
     name="tool"
     value="line"
     checked={tool === "line"}
     className="mt-1"
     onChange={(e) => setTool(e.target.value)}
    />
   </div>
   <div className="d-flex gap-1 align-items-center">
    <label htmlFor="rect">Rectangle</label>
    <input
     type="radio"
     name="tool"
     id="rect"
     checked={tool === "rect"}
     value="rect"
     className="mt-1"
     onChange={(e) => setTool(e.target.value)}
    />
   </div>
  </div>
  <canvas
   id="canvas"
   onMouseDown={handleMouseDown}
   onMouseUp={handleMouseUp}
   onMouseMove={handleMouseMove}
   width={window.innerWidth}
   height={window.innerHeight}
  ></canvas>
 </>
);

Now, let's test our application:

-

From the video above, we can see that when we select rectangles, we can draw rectangles on our whiteboard based on the mouse coordinates.

<CTA_Middle_Programming />

Enhancing Interactivity: Enabling Drag and Drop

To offer users the ability to drag and drop elements on the canvas, we'll do the following:

  • Introducing Selection Tool: Weโ€™ll provide users with a selection tool indicated by a radio button. This tool will allow users to interact with and move existing elements when the button is selected.
<input
  type="radio"
  id="selection"
  checked={tool === "selection"}
  onChange={() => setTool("selection")}
  />
<label htmlFor="selection">Drag n Drop</label>
  • Detecting Cursor Over an Element: To ascertain if the cursor is over an element, we'll implement a function named getElementAtPosition. This function will determine if the cursor is within the boundaries of any existing elements while the mouse is down.
const distance = (a, b) =>
 Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
const getElementAtPosition = (x, y) => {
 // Iterate through each element in the 'elements' array
 return elements.find((element) => {
  const { elementType, x1, y1, x2, y2 } = element;
  // Depending on the element type (line or rectangle), perform checks
  if (elementType === "rect") {
   // Check if the cursor position (x, y) falls within the boundaries of the rectangle
   const minX = Math.min(x1, x2);
   const maxX = Math.max(x1, x2);
   const minY = Math.min(y1, y2);
   const maxY = Math.max(y1, y2);
   return x >= minX && x <= maxX && y >= minY && y <= maxY;
  } else {
   // Check if the cursor is close enough to the line using a mathematical offset
   const a = { x: x1, y: y1 };
   const b = { x: x2, y: y2 };
   const c = { x, y };
   const offset = distance(a, b) - (distance(a, c) + distance(b, c));
   return Math.abs(offset) < 1;
  }
 });
};

The getElementAtPosition function takes in the current cursor coordinates (x and y) as parameters. We then use the .find() method to iterate through the elements array, which contains all the drawing elements on the canvas. We retrieve the elementType and its current coordinates for each element in the array. If the element is a rectangle, we calculate the minimum and maximum x and y values to define the rectangle's boundaries. We then check if the cursor's x coordinate falls within the range of the rectangle's x boundaries and if the cursor's y coordinate falls within the range of the rectangle's y boundaries. If both conditions are true, the cursor is positioned over the rectangle, so our function returns true. If the element is a line, we calculate the distance between the cursor's coordinates and the line segment defined by the element's x1, y1, x2, and y2 properties. We then compare the calculated offset to a small threshold value (in this case, 1). If the absolute value of the offset is less than the threshold, the cursor is considered to be positioned near the line segment, so our function returns true. The function returns false if the cursor is not positioned over any existing element.

  • Storing the Element to Drag: When the user presses the mouse down while the selection tool is active and the cursor is over an element, we'll store that element in a state, along with the initial offset between the cursor and the top-left corner of the element.
const handleMouseDown = (e) => {
 const { clientX, clientY } = e;
 // Check if the current tool is "Selection"
 if (tool === "selection") {
  // Find the element at the clicked position
  const element = getElementAtPosition(clientX, clientY);
  // If an element is found
  if (element) {
   // Calculate the offset from the top-left corner of the element
   const offsetX = clientX - element.x1;
   const offsetY = clientY - element.y1;
   // Store the selected element along with the offset
   setSelectedElement({ ...element, offsetX, offsetY });
   // Set the action to "moving" to indicate that dragging is in progress
   setAction("moving");
  }
 } else {
  // If the tool is not "Selection", execute code for drawing
  // ... (code for drawing)
 }
};
  • Updating Element Coordinates: In the handleMouseMove function, when the user is in the "moving" state (i.e., dragging an element), we calculate the new position of the element based on the mouse cursor's position and the initial offset. We then update the element's coordinates using the updateElement function:
const handleMouseMove = (e) => {
 const { clientX, clientY } = e;
 // Check if the current tool is "Selection"
 if (tool === "selection") {
  // Determine cursor style based on whether the mouse is over an element
  e.target.style.cursor = getElementAtPosition(clientX, clientY, elements)
   ? "move"
   : "default";
 }
 // Check the current action
 if (action === "drawing") {
  // ... (code for drawing)
 } else if (action === "moving") {
  // If in "moving" action (dragging an element)
  const { id, x1, x2, y1, y2, elementType, offsetX, offsetY } =
   selectedElement;
  const width = x2 - x1;
  const height = y2 - y1;
  // Calculate the new position of the dragged element
  const newX = clientX - offsetX;
  const newY = clientY - offsetY;
  // Update the element's coordinates to perform dragging
  const UpdatedElement = createElement(
   id,
   newX,
   newY,
   newX + width,
   newY + height,
   elementType,
  );
  const elementsCopy = [...elements];
  elementsCopy[id] = UpdatedElement;
  setElements(elementsCopy);
 }
};

Following these steps, we have equipped our canvas with a dynamic drag-and-drop capability. Users can now easily interact with existing elements, moving them across the canvas.

-

Creating the Real-Time Communication Server with Node.js

A powerful collaboration experience requires a server that seamlessly handles real-time communication between users. To set up our server, we'll do the following:

Install Required Dependencies

Before diving into the Server setup, we must ensure we have the tools in our toolkit. Use the following command to install the required dependencies on our Server:

npm install express cors socket.io
  • Express: A popular and flexible Node.js framework that simplifies the process of building robust web applications and APIs. It provides middleware and routing capabilities, making it ideal for creating server-side applications.

  • CORS (Cross-Origin Resource Sharing): A middleware package that enables Cross-Origin requests. In our case, we'll use it to ensure that our client application (running on a different origin) can interact with the server.

  • Socket.io: A real-time communication library that facilitates bidirectional communication between clients and the Server. It works over WebSocket connections but also gracefully degrades to other transport mechanisms when necessary.

Configuring Express and Importing Dependencies:

To begin the Server setup, create a file named server.js (or your chosen filename).

touch server.js

We'll then import the dependencies and set up the configuration for Express:

const express = require('express');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 5000;

Configure CORS and Start Server:

To ensure proper cross-origin communication, we'll configure CORS settings and start the Server:

const { createServer } = require("http");
const { Server } = require("socket.io");
const httpServer = createServer();
const io = new Server(httpServer, {
 cors: {
  origin: "http://localhost:3000", // Specify your front-end origin
  AccessControlAllowOrigin: "http://localhost:3000",
  allowedHeaders: ["Access-Control-Allow-Origin"],
  credentials: true,
 },
});
httpServer.listen(PORT, () => {
 console.log(`Server is listening on port ${PORT}`);
});

In this setup, we've created an Express app and set up the CORS configuration to allow communication between our client (running on port 3000) and Server. The socket.io library is integrated into the httpServer instance, enabling real-time communication.

Implementing Real-Time Communication.

To implement real-time collaboration between our users we'll have to configure our client (React-app) to connect with our Server by updating our Canvas component with the code below:

const [socket, setSocket] = useState(null);
// useEffect hook to establish and manage the socket connection
useEffect(() => {
 // Define the server URL
 const server = "http://localhost:5000";
 // Configuration options for the socket connection
 const connectionOptions = {
  "force new connection": true,
  reconnectionAttempts: "Infinity",
  timeout: 10000,
  transports: ["websocket"],
 };
 // Establish a new socket connection
 const newSocket = io(server, connectionOptions);
 setSocket(newSocket);
 // Event listener for successful connection
 newSocket.on("connect", () => {
  console.log("Connected to newSocket.io server!");
 });
 // Event listener for receiving served elements from the server
 newSocket.on("servedElements", (elementsCopy) => {
  setElements(elementsCopy.elements);
 });
 // Clean up the socket connection when the component is unmounted
 return () => {
  newSocket.disconnect();
 };
}, []); // Empty dependency array ensures the effect runs only once, during component mount

We will then leverage the event-driven architecture of socket.io, employing its on and emit mechanisms to facilitate seamless data transmission between the client and the server. On the client side, we will enhance the updateElement function to include a process whereby, with each element update, the data is transmitted to the server.

const updateElement = (id, x1, y1, x2, y2, tool) => {
 const UpdatedElement = createElement(id, x1, y1, x2, y2, tool);
 const elementsCopy = [...elements];
 elementsCopy[id] = UpdatedElement;
 setElements(elementsCopy);
 socket.emit("elements", elementsCopy);
};

Subsequently, our server will send the received data to other connected clients within the network. This ensures real-time synchronization and collaboration among all participants.

let connections = [];
let elements;
socket.on("elements", (data) => {
 elements = data;
 connections.forEach((con) => {
  if (con.id !== socket.id) {
   con.emit("servedElements", { elements });
  }
 });
});

When the data gets to other clients, we'll then update the elements state received, causing the useLayoutEffect to re-render, thus drawing the updated element on the canvas

new socket.on("servedElements", (elementsCopy) => {
 setElements(elementsCopy.elements);
});

With this done, whenever one client makes an update, all other clients connected to our Server receive the update. Now, let's test our application:

-

From the video above, we can see that once one client begins drawing, the other client receives the update and can add to the drawing, allowing for real-time collaboration by all clients on the network.

Conclusion

In this article, we've embarked on an exciting journey of creating a real-time collaboration board powered by the dynamic duo of React.js and Node.js, alongside the potent tools of socket.io and RoughJS. We've also delved into the realm of seamless teamwork, focusing on sketching lines and rectangles and implementing the drag-and-drop feature on the canvas. Additionally, more shapes and features can be integrated into this project. Beyond what we've already explored, RoughJS offers a treasure trove of inspiration to enhance your creations. Armed with React.js, Node.js, and the insights gained here, you can infuse your projects with the magic of real-time collaboration.

Resources

Github repo

Originally published at blog.openreplay.com.

ย