How I made a Code Editor in React (from scratch)
Hi folks, the fact that you are here means you are trying something out of the box. Awesome! But that also means that you are confused. So I am writing this article to hopefully send some of that confusion away.
Code Editor in React sounds like a very large thing to cover in a single article. So I will try to keep things simple and hence this article will cover 3 important topics.
Real Time Syntax Highlighting
Error Detection
Calculating output
I will leave the UI upto you.
Syntax Highlighting in real time
Okay, Lets start with the biggest one. The direct answer to this is very simple. You can just pass your text that you have in the input field to a function that identifies the keywords and returns highlighted text in the form of HTML. And you are absolutely correct, that the gist of it.
But, here is the biggest problem.
Lets say, to hightlight text in real time i.e. while you are writing your code, you will need to run this function everytime you type something and then update your input each time.
Well, to achieve that we can use a content-editable div instead of a input field. Since, we need to inject HTML back to it.
Okay, before you get confused, here is exactly what we will do:
First we will create a content-editable div.
Then on every change, we will scan the inputs for any javascript keyswords.
Now when we find one, we will wrap it up with a <span> element and give it style to hightlight it.
Now we inject out HTML back to our div and you have got your highlighted text.
But when you implement this, you will soon realise that each time you inject HTML into the div, the cursor position will reset back to the starting point.
But before we get into solving that, lets catch up to what we have discussed above. Now, keep in mind that finding keywords for any language is not an easy task. So we will use prism.js to do that for us. (saves us a lot of manual labour).
Here is what our function looks like till now.
let editor = document.getElementById("main_input");
const text = editor ? editor.innerText : "";
// Generate the highlighted code using Prism
var html = Prism.highlight(
text,
Prism.languages.javascript,
"javascript"
);
// Inject the new highlighted code
if (editor != null) {
editor.innerHTML = html;
}
Alright, now that we have basics sorted out. Lets dive into our kryptonite. The cursor problem :(
Well, this solution is not perfect. But it works. I am sure people way smarted than me will optimize this easily. But for now bear with me.
first we calculate the cursor position before making any changes.
Next, after we inject the HTML to the div, we restore the cursor to the same position.
Sounds simple right? But the implementation is ugly. So before I show you the code, let me tell you the theory of how we calculate the cursor position.
JavaScript sees everything in HTML as a node. So, a plain text is seen as a single Text node. But according to our requirenment, we will have a chain of Text nodes and Span nodes to deal with.
In the above image, the coloured texts are actually <span> and the white text is Text node. So to calculate the cursor position in the above line, we need to iterate backwards from the current node the cursor is on, to the very first node and store the total offset in a variable.
// Get the cursor position before doing any changes
var currentPosition;
var selection = window.getSelection();
// loops through all the nodes backwards starting from the current node that cursor is on
// to the first node and calculate the total offset of the cursor pointer
if (selection!.rangeCount > 0) {
var range = selection!.getRangeAt(0);
var startOffset = range.startOffset;
var totalOffset = startOffset;
var mainNode: any = range.startContainer;
while (mainNode.parentElement != editor) {
mainNode = mainNode.parentElement;
}
mainNode = mainNode.previousSibling;
while (mainNode != null) {
if (mainNode.nodeType === Node.TEXT_NODE) {
totalOffset += mainNode.nodeValue!.length;
} else {
totalOffset += mainNode.innerText.length;
}
mainNode = mainNode.previousSibling;
}
console.log(
"Total offset: " + totalOffset.toString() + "for text: " + text
);
currentPosition = totalOffset;
}
Now that we have got the inital postion, our next step is to restore it back after we update our HTML.
Now this time, we will start iterating from the very first node and go to the requred node by subtracting the stored offset. Then we will restore the cursor to that position.
var childNodes = editorRef.current.childNodes;
var count = 0; // Maintaines the the offset for the new cursor position
var totalCount = 0; // total count of the index while looping thorough the nodes
var currentNode = null;
// Loop through all child nodes and find the current node that the cursor was on.
// find the offset that tell exactly which position the cursor was on in the current node
for (var node of childNodes) {
// nodeType = 3 means its a text node
if (node.nodeType == 3) {
totalCount += node.length;
if (count + node.length < currentPosition) {
count += node.length;
}
// nodeType = 1 means its a span node
} else if (node.nodeType == 1) {
if (count + node.innerText.length < currentPosition) {
count += node.innerText.length;
}
totalCount += node.innerText.length;
}
if (totalCount >= currentPosition) {
currentNode = node;
break;
}
}
// Restore the cursor position
const selection = window.getSelection();
const range = document.createRange();
// TODO - optimize this
try {
// in case its a span node, keep doing currentNode.firstChild until you get the text node
while (currentNode.nodeType != 3) {
currentNode = currentNode.firstChild;
}
// set the cursor position of the text node
range.setStart(currentNode, currentPosition - count);
} catch (e) {
console.log("error is : ", e);
// set cursor position to the end of the currentnode in case of an error
range.setStartAfter(currentNode);
}
range.collapse(true);
selection!.removeAllRanges();
selection!.addRange(range);
Okay, so we are done with syntax highlighting. This is how your editor should look like now.
You can find the whole highlight function here - https://github.com/Sardar1208/code-editor/blob/master/src/app/page.tsx
Error Detection
function parseCode() {
var editor = document.getElementById("main_input");
const text = editor ? editor.innerText : "";
try {
var res = espree.parse(text, {
ecmaVersion: 2023,
ecmaFeatures: { jsx: true },
});
setError(null);
} catch (e: any) {
console.log(e);
var error: CodeError = {
lineNumber: e.lineNumber || 0,
index: e.index || 0,
message: e.message || "",
type: e.name || "",
};
setError(error);
}
}
So basically here we take our code and parse it using espree. the best way to check for errors is to parse the code. If it succeds, you are the GOAT. Otherwise, there is an issue. We use a simple try catch block to catch that issue. And I am storing that error in a custom CodeError object.
I'll leave the UI to you but this is how I display that error. Pretty neat right :)
Displaying output
Now since we are making a javascript editor, the only way to display output is via console.log() statements. So, all we need to do is override the console.log() such that it captures the content that would have been originally logged to the console.
Here are the steps to do that:
find and store all console.log statements in the code
override the console.log method to capture its output
revert it back to its original form
function exceuteCode() {
var editor = document.getElementById("main_input");
var output = document.getElementById("output");
const text = editor ? editor.innerText : "";
try {
// replace console.logs with log.
const log: any = [];
const originalConsoleLog = console.log;
console.log = (...args) => {
log.push(...args);
originalConsoleLog(...args);
};
// exceute the code
var F = new Function(text);
F.apply(null);
// revert the console.log change
console.log = originalConsoleLog;
// create result in a new Node
const result = document.createElement("div");
log.forEach((element: string) => {
const text = document.createElement("div");
text.innerText = element;
result.appendChild(text);
});
// Display the output in your UI
output?.replaceChildren(result);
} catch (e) {
console.log("errors found: ", e);
}
}
And there you go, you have a scrappy code editor. If you stuck around till now here is the repo for this whole thing - https://github.com/Sardar1208/code-editor
In this repo you can find extra things like managing number of lines in out editor :)
Conclusion and next steps
So, now that you have seen what I did to achieve this, here is what can be improved but I am very lazy to do.
Instead of iterating through the whole text to calculate the cursor postion, we can just do the calculation on the that partucular line. This will optimise the process by a lot.
Anyway, I'll leave this to people smarter than me. If you guys liked it drop a like and let me know. cheers :)
Subscribe to my newsletter
Read articles from sarthak bakre directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
sarthak bakre
sarthak bakre
I am a Software Developer with experience in React native and Flutter. I like to cover some out of the box topics in my blogs. Follow me if you like the content :)