Building a real-time collaborative whiteboard with Froala text editor — part 2.

boyanlevchev
Le Wagon
Published in
14 min readJul 30, 2020

--

Hi! I’m Boyan, web developer and recent graduate from Le Wagon, London. This is part 2 of a tutorial on building a collaborative whiteboard app with React and the Froala WYSIWYG text editor. You can read the intro here if you want to see where we are starting from, and get a quick introduction to this project.

“Agatha Appears” — Net Art by Olia Lialina
“Agatha Appears” — Net Art by Olia Lialina — experience it here

Using Redux to allow our components to communicate

First, ensuring that the CLI is in your project’s directory, run:

npm install redux react-redux

Now, our React app needs a quick little update, to make it ready to use Redux. Go to the src directory, and open the index.js file. Replace everything on the page, with the following code — code is in bold, while comments in non-bold have been added to describe the new lines we are adding:

import React from 'react';
import ReactDOM from 'react-dom';
//This line imports the provider for Redux - it is what sends data from the store to your app
import { Provider } from 'react-redux';
//the Store is the data mothership - it sends out and accepts data to/from wherever it is called.
import { createStore, combineReducers, applyMiddleware } from 'redux';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
//Below is where we combine all the reducers. Reducers determine what happens to your app when we send an action to the Store
const reducers = combineReducers({
});
//Below we wrap our app in the Provider, thus allowing all components in the app to be able to access the provider
//Within the provider, we pass the Store prop which creates the store from all the reducers and add-ons we will use
ReactDOM.render(
<Provider store={createStore(reducers, {}, applyMiddleware())}>
<App />
</Provider>,
document.getElementById('root')
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

Let’s just make sure the app still works by checking your browser at localhost:3000. All good?

Now, at the moment our app just has a static Froala editor component that is there as soon as the page loads. We want to be able to add content dynamically wherever we choose on the whiteboard! So let’s make the whiteboard interactive by giving it what is called an ‘event-handler’ — so if the user clicks on the whiteboard it will trigger a function.

If we stop to think about how this handleClick() function should work, you may realize that if the whiteboard creates a new editor on any click of the mouse, then we may have unwanted side effects when, for example, a user simply wants to click inside of an editor to edit it, or wants to then subsequently click outside of an editor to stop editing. So one solution is to combine our handleClick function with a timer, that allows the app to know if a user has double-clicked — an action that is more specific, and therefore has less crossover with other potential actions.

Go to whiteboard.jsx and add in the line of code in bold below, inside the opening <div> tag, right after the id we just created above:

render(){
return(
<div id=”whiteboard” onClick={this.handleClick}>
<Froala/>
</div>
)
}

What the code in bold above does is tell the canvas that, when it hears a *click* from the mouse, it will trigger the function handleClick.

Since we will be checking for a double-click, we should add this to the component’s state in the constructor. In addition, we will need some state that can hold information on the contents and position of an editor that we add, as well as an editor ID counter which tracks how many editors we’ve created and allows us to create a unique identifier for each editor i.e. the first editor I add will be called editor0 and the 6th editor I add will be editor5:

constructor(props) {
super(props);
this.state = {
firstClick: 0, // Will store information about a first click
editorComponents: {},
// Stores the editor position and content
editorIDs: 0
// Stores the ID of the last editor made
};
}

With that prepared, let’s create the handleClick function:

handleClick = (event) => {// This if statement exists to check if we have already clicked once 
// on the screen. If we have not already clicked, then it is the
// first click in a potential double-click, and so we assign the
// current time to the component's firstClick state. We assume then
// that if a second click does not follow the first click, then it
// was simply a single click - we can check this by adding a simple
// setTimeout function: if 250 milliseconds elapse since the first
// click, then we reset the firstClick state to 0.
if (this.state.firstClick === 0) {
this.setState({
firstClick: new Date()
// assign the current time to firstClick
});
setTimeout(() => {this.setState({firstClick: 0})}, 250)
//reset the firstClick state to 0 after 250 milliseconds
// If we have already clicked once on the screen, and 250
// milliseconds have not elapsed, then this part of the if statement
// is called. We assign a time to the second click, and we subtract
// the firstClick from the secondClick, and it is less than 250 (the
// amount of time between clicks) then we can consider the sequence
// of clicks a double-click. Thus, we trigger a new function called
// doubleClick() which will tell the whiteboard to create a new
// editor instance on-screen.
} else {
let secondClick = new Date();
if ((secondClick - this.state.firstClick) < 250) {
this.doubleClick(event);

// Above we call the doubleClick function. The 'event' inside the
// parentheses is an item passed automatically from the onClick
// event handler we added above to the <div/> with "id = whiteboard"
// in the render function. This event item holds a bunch of
// different values that relate to the click event, which can then
// be accessed and used.
}
}
}

Now we must also add the doubleClick function:

doubleClick = (event) => {// this if statement checks the event item that we passed through
// from the handleClick function. This event item holds information
// about what item on the DOM was clicked. Below we are checking
// to see if the item clicked has the id "whiteboard". We check for
// this, because if there are already editors on screen, and we
// double click inside of an editor, we do not want this to trigger
// the creation of a new editor. We only want a new editor when
// double-clicking on the whiteboard.
if (event.target.id === "whiteboard"){
const x = event.clientX
//saves the x position of the mouse
const y = event.clientY
//saves the y position of the mouse
// if our editorID counter is at 0, we create a new editor
// instance by adding an object to editorComponents. This object
// will have an identifying key, and will contain a string called
// html (the inner content of the editor) and the x and y
// coordinates of the editor on the screen. We then increment the
// editorID counter, so it can be used to identify the next editor
// that we add to the whiteboard.
if ( this.state.editorIDs === 0) {
const key = `editor0`
this.setState({
editorIDs: 1,
editorComponents: {[key]: {html: "", x:x, y:y}}
})
// in the editorComponents state, we put in an empty html string
// (since the editor will be empty when we first create it) and will
// make its x and y coordinates the same as the place we double-
// clicked on the screen.
// if we already have editor information in the editorComponents
// state and we have incremented the editorIDs counter, then we
// trigger this part of the statement instead. The big difference
// here is in the setState() function, we use a built-in argument
// called prevState, which allows us to copy over everything that
// exists in the editorComponents state, and then append a new
// editor object. If we don't do this, the new editor object would
// simply replace everything in the editorComponents state rather
// than get added consecutively.
} else {
let id = this.state.editorIDs
let key = `editor${id}`
this.setState( prevState => ({
editorIDs: (id + 1),
editorComponents: {
...prevState.editorComponents,
[key]: {html: "", x:x, y:y}
}
}))
}
}
}

Finally, we need to set up the render() function so that, for every object in the editorComponents state it should add an editor to the whiteboard. Simply wrap the <Froala/> component in a map() function and attach the information we are storing in the editorComponents state as props to the <Froala/> component:

{Object.keys(this.state.editorComponents).map( editor => {
return <Froala id={editor}
x={this.state.editorComponents[editor].x}
y={this.state.editorComponents[editor].y}
html={this.state.editorComponents[editor].html}
key={editor}/>
})}

We have now set up the building blocks for the rest of the app! If you go to localhost:3000 in your browser, and double click on the page, a new editor should appear! Yay!

Except, you will see that it doesn’t actually appear where your mouse clicks…

So to complete this functionality, we have to go to the froala.js file and add one last little thing to our Froala component.

In the render() function (but before the return) add:

const style = {
position: 'absolute',
left: this.props.x,
top: this.props.y,
minWidth: '170px'
}

And then add this style constant as a prop to the <div/> holding the <FroalaEditor/> component in the return:

return(
<div style={style}>
<FroalaEditor config={config}/>
</div>
)

Now cross your fingers, close your eyes, and wish upon a shooting star — if you go back to localhost:3000 and double click anywhere, the Froala editor should appear right there!

Cool!

Next, we want to have the ability to move our newly added editors, so they can sit anywhere on the page! And this is where Redux comes in. In the src folder create two new folders called actions and reducers. Then, in the actions folder create a new file called index.js .

We are going to want to create 2 actions — one that allows us to turn on and off the “draggability” of an editor, and one that transfers the information from the editor that is being dragged back to the parent. Now there are a variety of ways one could do this, and some might be simpler — if you have any ideas for refactoring and making our code leaner, please fire away in the comments!

Add these two actions into index.js :

export function setDragging(editor) {
return {
type: 'SET_DRAGGING',
payload: editor
}
}
export function setCanvasDraggable(boolean) {
return {
type: 'SET_CANVAS_DRAGGABLE',
payload: boolean
}
}

After that, in your new reducers folder, add two files called set_dragging_reducer.js and set_canvas_draggable_reducer.js. These two files will listen for the payload type sent by the actions we created above.

In set_dragging_reducer.js add:

const setDraggingReducer = (state, action) => {
if(state === undefined) {
return null;
}
if (action.type === 'SET_DRAGGING') {
return action.payload;
} else {
return state;
}
}
export default setDraggingReducer;

And in set_canvas_draggable_reducer.js add:

const setCanvasDraggable = (state, action) => {
if(state === undefined) {
return false;
}
if (action.type === 'SET_CANVAS_DRAGGABLE') {
return action.payload;
} else {
return state;
}
}
export default setCanvasDraggable;

Now, all that Redux lingo we updated the index.js in the src folder with will finally come in use, as we attach the above reducers into the ReduxStore . Go to index.js and import reducers as shown, just after the line importing serviceWorker:

//import the two reducers from their respective files in the reducers folder
import setDraggingReducer from './reducers/set_dragging_reducer'
import setCanvasDraggable from './reducers/set_canvas_draggable_reducer'

And then inside the combineReducers function which we are assigning to the const reducers, we want to add our reducers as shown in the code below:

//add the two reducers into a const called reducers, that will then be added to the store prop in the <Provider/>const reducers = combineReducers({
draggableEditor: setDraggingReducer,
canvasDraggable: setCanvasDraggable

});

We’re almost there!

We need to make a few more adjustments to our Froala and Whiteboard components that will allow them to pass data into one another by using the Redux actions we created. At the top of both froala.jsx and whiteboard.jsx we have to import the Redux packages we need:

import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';

And then we need to import the actions that we created — it would make sense that toggling “draggability” is an action controlled by the parent — Whiteboard.jsx — while sending information about the child actively being dragged should come from the child — Froala.jsx. So import the relevant actions at top:

froala.jsximport { setDragging } from '../actions'..............whiteboard.jsximport { setCanvasDraggable } from '../actions'

And then at the bottom of each file we must change how each component is exported and add some new functions (note that this is happening outside of the component, just before the export). Add the following functions, and change the export as follows:

whiteboard.jsxfunction mapDispatchToProps(dispatch) {
return bindActionCreators( {
setCanvasDraggable: setCanvasDraggable
},
dispatch
);
}
function mapReduxStateToProps(reduxState) {
return {
canvasDraggable: reduxState.canvasDraggable,
draggableEditor: reduxState.draggableEditor
}
}
export default connect(mapReduxStateToProps, mapDispatchToProps)(Whiteboard);............froala.jsxfunction mapDispatchToProps(dispatch) {
return bindActionCreators( {
setDragging: setDragging
},
dispatch
);
}
function mapReduxStateToProps(reduxState) {
return {
canvasDraggable: reduxState.canvasDraggable
}
}
export default connect(mapReduxStateToProps, mapDispatchToProps)(Froala);

Finally, we can use our Redux actions within the Froala and Whiteboard classes, and transfer information.

So — how will we do this? The quickest way I could think of to get up and running with a way to toggle the “draggability” of items on the Whiteboard is to simply have to press and hold a key on your keyboard and then drag with the mouse.

Let’s use the command key on Mac. To let our app know what key has been pressed, we will need to know its key code — you can find the key code for any key on your keyboard here: https://keycode.info/

In whiteboard.jsx start with adding the event handlers to the <div> with id “whiteboard” (if these event handlers seemingly don’t work at first, try clicking in the window to make sure it’s focused).

<div id="whiteboard" onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
>

{Object.keys(this.state.editorComponents).map( editor => {
return <Froala id={editor}
x={this.state.editorComponents[editor].x}
y={this.state.editorComponents[editor].y}
html={this.state.editorComponents[editor].html}
key={editor}/>
})}
</div>

Then create the corresponding functions for the event handlers, just before the render() function:

handleKeyDown = (event) => {
//we check if the key pressed has code 91 or 93 (command key)
if ( event.keyCode === 91 || event.keyCode === 93 ){
//we change the cursor to have nice visual feedback to the button press
document.documentElement.style.cursor = "grab";
//
and finally, we call our Redux action, sending a boolean value -true- and starting "draggability"
this.props.setCanvasDraggable(true);
}
}
handleKeyUp = (event) => {
// we check if the command key has been released
if ( (event.keyCode === 91 || event.keyCode === 93)){
// turn the mouse cursor back to normal
document.documentElement.style.cursor = "default";
// send -false- to our Redux action, thus stopping "draggability"
this.props.setCanvasDraggable(false);
}
}

Let’s check if this worked really quickly — go to localhost:3000 and hold down the command key — does the mouse change? Then at least half should be working! Next, we can check whether our Redux action is sending correctly.

To be able to actually drag an item, we have to somehow tell the Whiteboard which editor we have clicked. We can do this by creating a click event handler that sends over the id of the editor back into the Whiteboard via the setDragging action.

So, let’s go to froala.jsx and add an event handler to the<div/> containing the <FroalaEditor/> component:

return(
<div style={style} onMouseDown={this.handleMouseDown} >
<FroalaEditor config={config}/>
</div>
)

And then create the handleMouseDown function, which should go just above the render() function:

handleMouseDown = (event) => {
// if the Redux prop canvasDraggable is -true- then call the
// Redux prop setDragging and send through the id of the editor
if (this.props.canvasDraggable) {
this.props.setDragging({key: this.props.id})
}
}

Now we should be able to access the editor’s id through the Whiteboard. Let’s go to whiteboard.jsx and add two more event handlers to the <div/> with id=”whiteboard”

<div id="whiteboard" onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onMouseMove={this.handleMouseMove}
onMouseUp={this.handleMouseUp}
>

The onMouseMove event will fire any time the mouse is moved, while the onMouseUp will allow us to deselect the currently-dragging editor so that its drag trajectory will complete. Let’s add the first function:

handleMouseMove = (event) => {  // We check if there is a draggableEditor (i.e. an editor actively
// selected for dragging) as well as if the "draggability" toggle
// is on
if(this.props.draggableEditor && this.props.canvasDraggable){
const key = this.props.draggableEditor.key
const html = this.state.editorComponents[key].html
const x = event.clientX
//this is the current mouse X position
const y = event.clientY
//this is the current mouse Y position
if (x && y){

// The prevState defined below allows us to access
// the component's previous state - useful as we
// don't want to overwrite it, but add to it
// when setting the new state
this.setState( prevState => ({
// This method of combining two Objects is
// known as Spread Syntax and follows the format
// Object = {...originalObject, newKeyValuePair}
editorComponents: {
...prevState.editorComponents,
[key]: {html: html, x:x, y:y}
}

}))
}
}
}

Give it a try on localhost:3000 — you should be able to hold down Command and then drag the editor around! Yay! That’s pretty much the core functionality done. You can add an editor, inside of those editors you can add any kind of video, image and text, and now you can position them exactly where you want!

There are a few little things we need to clean up here though:

  1. The editor jumps to the right when you start dragging it, which is because we are setting the editor’s top-left most point to the mouse each time, rather than the point we are grabbing.
  2. If you drag once to position an editor, upon pressing Command again, the editor’s position will suddenly snap to your new mouse position — we haven’t set up a function that stops the dragging!

So, let’s fix the first problem — in froala.jsx go back to the handleMouseDown function. We are going to store the exact position we click on and pass that through to handleMouseMove function in whiteboard.jsx so that it always moves the editor in relation to that starting point.

handleMouseDown = (event) => {
if (this.props.canvasDraggable) {

//we store the current x and y positions of the mouse and
//calculate their difference with editor's x and y position
//as the x axis offset, and y axis offset

const yOffset = (event.clientY-this.props.y)
const xOffset = (event.clientX-this.props.x)

//We check first to ensure xOffset and yOffset have been
//created, and then we pass them as values through the
//setDragging Redux prop, with keys of the same name

if (xOffset && yOffset){

this.props.setDragging({key: this.props.id,
yOffset: yOffset,
xOffset: xOffset})

}
}
}

And then, back to whiteboard.jsx we change the handleMouseMove function as such:

handleMouseMove = (event) => {
if(this.props.draggableEditor && this.props.canvasDraggable){
const key = this.props.draggableEditor.key
const html = this.state.editorComponents[key].html
//this is the current mouse X position minus the difference
//between its position and the left edge of the editor
const x = (event.clientX - this.props.draggableEditor.xOffset)

//this is the current mouse Y position minus the difference
//between its position and the top edge of the editor
const y = (event.clientY - this.props.draggableEditor.yOffset)

if (x && y){
this.setState( prevState => ({
editorComponents: {
...prevState.editorComponents,
[key]: {html: html, x:x, y:y}
}
}))
}
}
}

And we should see the effect immediately! Woohoo! We’re dragging!

Now we must fix the second problem, and add a function that will stop the dragging. It’s as simple as adding the below function right after handleMouseMove in whiteboard.jsx — if you recall we already added the event handler to the <div/> with id=whiteboard

handleMouseUp = () => {
this.props.setDragging(null)
}

And we’re done! 🍾🥂

But right now, if you add things to the board and then refresh the page — everything gets lost! How do we save it so that we can always return to the same board? How can we get other people to contribute? There’s still just as much to do to get this to be a real-time multi-user whiteboard.

The next article in this series will cover building our back-end database using Google Firebase! This will also give us real-time functionality and allow other simultaneous users to see updates as soon as you make them.

>> Click Me!<< (Part 3 coming soon…)

--

--

boyanlevchev
Le Wagon

I recently started coding - and it's my new favorite thing. I'm just here to share the things I come across along my way. At night, I secretly produce music.