React: Code Editor

Hello friends!
In this short tutorial, I will show you how to develop a simple code editor in React
.
note: the tutorial is designed mainly for beginner developers, although I dare to hope that experienced ones will find something interesting in it for themselves.
The functionality of our application will be as follows:
- there are three tabs: for manual editing
HTML
,CSS
andJavaScript
respectively; - the user has the ability to upload files corresponding to the current tab;
- the user has the ability to drop (drop) files corresponding to the current tab;
- the code entered by the user is loaded into
iframe
and is executed in sandbox mode when the corresponding button is pressed.
If you are interested, please under cat.
To develop the editor, we will use 2 libraries:
- CodeMirror – universal text editor
JavaScript
for a browser designed for editing code, supporting more than 100 programming languages and providing a large number of different plug-ins (addons) for implementing advanced functionality; - react-codemirror2 – abstraction over
codemirror
forreact
.
To create an application template, we will use Vite
.
For styling – Sass.
To install dependencies − Yarn.
Create an application template:
# code-editor - название проекта
# --template react - используемый шаблон
yarn create vite code-editor --template react
Go to the created directory and install codemirror
and react-codemirror2
as production dependencies, and sass
as a development dependency:
cd code-editor
yarn add codemirror react-codemirror2
yarn -D sass
Directory structure src
will be as follows:
- components
- Button.jsx - кнопка
- CodeEditor.jsx - компонент для редактирования кода
- CodeExecutor.jsx - компонент для выполнения кода
- Tabs.jsx - компонент для переключения вкладок
- ThemeSelector.jsx - компонент для выбора темы для редактора кода
- App.jsx
- App.scss
- main.jsx
When executing the code react-codemirror2
an error may occur ReferenceError: global is not defined
. In order to fix this bug, you need to add to index.html
this line:
<script>
window.global = window
</script>
Default event handling drop
in the browser involves opening an “abandoned” file in a new tab (see below). Drag and Drop API
). We will process the specified event ourselves, so we disable its processing by default in main.jsx
:
window.ondrop = (e) => {
e.preventDefault()
}
Let’s start implementing the components.
Let’s start with the simplest – buttons (components/Button.jsx
):
// функция принимает название класса, текст и обработчик нажатия кнопки
export const Button = ({ className, title, onClick }) => (
<button className={className} onClick={onClick}>
{title}
</button>
)
I think everything is clear here.
The language supported by the code editor is determined by the prop mode
(mode) component Controlled
from react-codemirror2
. Therefore, to switch the tab, we just need to switch the editor mode. We implement this logic in the component Tabs
(components/Tabs.jsx
):
// импортируем кнопку
import { Button } from './Button'
// определяем массив режимов (они же являются текстами кнопок)
const tabs = ['HTML', 'CSS', 'JS']
// функция принимает режим и метод для его установки
export const Tabs = ({ mode, setMode }) => {
// TODO
}
We define a function to set the mode:
const changeMode = ({ target: { textContent } }) => {
// значение режима - текст кнопки в нижнем регистре
setMode(textContent.toLowerCase())
}
Returning markup:
return (
<div className="tabs">
{tabs.map((m) => (
<Button
key={m}
title={m}
onClick={changeMode}
// индикатор текущей вкладки
className={m.toLowerCase() === mode ? 'current' : ''}
/>
))}
</div>
)
The theme of the editor is determined by the prop theme
component Controlled
. To use the theme, just import the desired CSS-файл
to the component code. codemirror
provides a large set of ready-made themes, the demo of which can be viewed here. We will take 3 topics: dracula
, material
and mdn-like
. Importing themes in a component ThemeSelector
(components/ThemeSelector.jsx
):
// импортируем темы
import 'codemirror/theme/dracula.css'
import 'codemirror/theme/material.css'
import 'codemirror/theme/mdn-like.css'
// определяем массив тем
const themes = ['dracula', 'material', 'mdn-like']
// функция принимает метод для установки темы
export const ThemeSelector = ({ setTheme }) => {
// TODO
}
Define a function to set the theme:
const selectTheme = ({ target: { value } }) => {
setTheme(value)
}
Returning markup:
return (
<div className="theme-selector">
<label htmlFor="theme">Theme: </label>
<select id='theme' name="theme" onChange={selectTheme}>
{themes.map(
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
)
Let’s define a simple code editor (components/CodeEditor.jsx
).
Import hooks, wrapper and components:
// хук
import { useState } from 'react'
// обертка
import { Controlled } from 'react-codemirror2'
// компоненты
import { Button } from './Button'
import { ThemeSelector } from './ThemeSelector'
Import default editor styles and modes:
// стили
import 'codemirror/lib/codemirror.css'
// режимы
import 'codemirror/mode/xml/xml'
import 'codemirror/mode/css/css'
import 'codemirror/mode/javascript/javascript'
// функция принимает режим, код и метод для его изменения
export const CodeEditor = ({ mode, value, setValue }) => {
// TODO
}
Define the local state for the theme:
// дефолтной темой является `dracula`
const [theme, setTheme] = useState('dracula')
We define a function to change the code:
// нас интересует только последний аргумент, передаваемый функции
const changeCode = (editor, data, value) => {
setValue(value)
}
Returning markup:
<div className="code-editor">
{/* компонент для выбора темы */}
<ThemeSelector setTheme={setTheme} />
{/* обертка */}
<Controlled
value={value}
onBeforeChange={changeCode}
// настройки
options={{
// режим (условно, текущий язык программирования)
mode,
// тема
theme
}}
onDrop={onDrop}
/>
</div>
)
Let’s define a few more settings to make our editor more user friendly:
options={{
mode,
theme,
// new
lint: true,
lineNumbers: true,
lineWrapping: true,
spellcheck: true
}}
lint: true
: turn on “linting” code;lineWrapping: true
: when the end of the line is reached, a line break is performed (this avoids horizontal scrolling);lineNumbers: true
: line numbering;spellcheck: true
: Check spelling.
For a complete list of available settings, see here.
We will also add a couple of plugins. We import them:
import 'codemirror/addon/edit/closetag'
import 'codemirror/addon/edit/closebrackets'
import 'codemirror/addon/edit/matchtags'
import 'codemirror/addon/edit/matchbrackets'
And pass in the settings:
options={{
mode,
theme,
lint: true,
lineNumbers: true,
lineWrapping: true,
spellcheck: true,
// new
autoCloseTags: true,
autoCloseBrackets: true,
matchTags: true,
matchBrackets: true
}}
autoCloseTags: true
– automatic affixing of closing HTML tags;autoCloseBrackets: true
— automatic affixing of closing brackets;matchTags: true
— highlighting paired tags;matchBrackets: true
— highlighting paired brackets.
For a complete list of available plugins, see here.
We rise to the parent component (App.jsx
).
Import styles, hook and components:
import './App.scss'
import { useState } from 'react'
import { Tabs } from './components/Tabs'
import { CodeEditor } from './components/CodeEditor'
Determine initial values HTML
, CSS
and JS
for editor:
// заголовок с текстом `hi`
const initialHTML = '<h1>hi</h1>'
// зеленого цвета
const initialCSS = `
h1 {
color: green;
}
`
// при клике текст заголовка меняется на `bye`,
// а цвет становится красным
// обработчик является одноразовым
const initialJavaScript = `
document.querySelector("h1").addEventListener('click', function () {
this.textContent = "bye"
this.style.color = "red"
}, { once: true })
`
export default function App() {
// TODO
}
We define a local state for the mode, HTML
, CSS
and JS
:
// режимом по умолчанию является `HTML`
const [mode, setMode] = useState('html')
const [html, setHtml] = useState(initialHTML)
const [css, setCss] = useState(initialCSS.trim())
const [js, setJs] = useState(initialJavaScript.trim())
One caveat: the modes we are interested in are named xml
, css
and javascript
v codemirror
however in components we use other names − html
instead of xml
and js
instead of javascript
.
Define an object with props for the editor:
const propsByMode = {
html: {
mode: 'xml',
value: html,
setValue: setHtml
},
css: {
mode: 'css',
value: css,
setValue: setCss
},
js: {
mode: 'javascript',
value: js,
setValue: setJs
}
}
The object’s keys are “local” modes.
Returning markup:
return (
<div className="app">
<h1>React Code Editor</h1>
<Tabs mode={mode} setMode={setMode} />
{/* распаковываем объект */}
<CodeEditor {...propsByMode[mode]} />
</div>
)
$primary: #0275d8;
$success: #5cb85c;
$warning: #f0ad4e;
$danger: #d9534f;
$light: #f7f7f7;
$dark: #292b2c;
@mixin reset($font-family, $font-size, $color) {
margin: 0;
padding: 0;
box-sizing: border-box;
@if $font-family {
font-family: $font-family;
}
@if $font-size {
font-size: $font-size;
}
@if $color {
color: $color;
}
}
@mixin flex-center($column: false) {
display: flex;
justify-content: center;
align-items: center;
@if $column {
& {
flex-direction: column;
}
}
}
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;400;600&display=swap');
*:not(.react-codemirror2 *) {
@include reset('Montserrat', 1rem, $dark);
}
.app {
@include flex-center(true);
margin: 0 auto;
max-width: 600px;
h1 {
margin: 1rem 0;
font-size: 1.6rem;
text-align: center;
}
.tabs {
button {
margin: 0.5rem;
padding: 0.5rem;
background: none;
border: none;
outline: none;
border-radius: 6px;
transition: 0.4s;
cursor: pointer;
user-select: none;
&:hover,
&.current {
background: $dark;
color: $light;
}
}
}
.theme-selector {
margin: 0.75rem 0;
text-align: center;
select {
border-radius: 4px;
}
}
.code-editor {
width: 100%;
.CodeMirror-wrap {
border-radius: 4px;
padding: 0.25rem;
}
}
.code-executor {
width: 100%;
}
.btn {
margin: 0.75rem;
padding: 0.25rem 0.75rem;
background: $primary;
border: none;
border-radius: 4px;
outline: none;
color: $light;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
cursor: pointer;
user-select: none;
transition: 0.3s;
&:active {
box-shadow: none;
}
&.run {
background: $success;
}
}
iframe {
padding: 0.25rem;
width: 100%;
border: 1px dashed $dark;
border-radius: 4px;
}
}
Execute the command yarn dev
to launch the development server and open a browser tab at http://localhost:3000
:
Our editor is functional: we can switch between tabs, edit code, and change themes.
But what’s the use of code that can’t be executed?
In the simplest case, to perform HTML+CSS+JS
element can be used iframe
. We are interested in 2 attributes of this element:
srcdoc
: allows you to upload toiframe
inlineHTML
;sandbox
: allows you to impose restrictions on what is loaded iniframe
content.
We implement a component for executing code (components/CodeExecutor.jsx
):
import { Button } from './Button'
// функция принимает значение атрибута `srcdoc` и метод для изменения этого значения
export const CodeExecutor = ({ srcDoc, runCode }) => (
<div className="code-executor">
<Button className="btn run" title="Run code" onClick={runCode} />
<iframe
srcDoc={srcDoc}
title="output"
// разрешаем выполнение скриптов
sandbox='allow-scripts'
/>
</div>
)
We return to App.jsx
and import CodeExecutor
:
import { CodeExecutor } from './components/CodeExecutor'
Define local state for an attribute srcdoc
and a method to set its value:
const [srcDoc, setSrcDoc] = useState('')
const runCode = () => {
setSrcDoc(
`<html>
<style>${css}</style>
<body>${html}</body>
<script>${js}</script>
</html>`
)
}
Passing the appropriate props to the component CodeExecutor
:
<CodeExecutor srcDoc={srcDoc} runCode={runCode} />
Back to the browser:
Click on the button Run code
:
We see that our code is successfully executed. Cool!
It remains to implement the loading and throwing of files.
We implement file upload using a hidden input and several methods in the component CodeEditor
.
We define an immutable variable to store a reference to the input:
const fileInputRef = useRef()
We define a method for checking the validity of the file, i.e. that the file being loaded corresponds to the current tab mode:
const isFileValid = (file) =>
(mode === 'xml' && file.type === 'text/html') || file.type.includes(mode)
Define a method to read the file as text with FileReader
:
const readFile = (file) => {
if (!isFileValid(file)) return
// создаем экземпляр `FileReader`
const reader = new FileReader()
// обрабатываем чтение файла
reader.onloadend = () => {
// обновляем значение кода
setValue(reader.result)
}
// читаем файл как текст
reader.readAsText(file)
}
We define a method for downloading a file:
const loadFile = (e) => {
const file = e.target.files[0]
readFile(file)
}
Add a button and an input to the markup:
<Button
className="btn file"
title="Load file"
onClick={() => {
// передаем клик скрытому инпуту
fileInputRef.current.click()
}}
/>
<input
type="file"
accept="text/html, text/css, text/javascript"
style={{ display: 'none' }}
aria-hidden='true'
ref={fileInputRef}
// выполняем загрузку и чтение файла
onChange={loadFile}
/>
In a similar way, we define a function for handling file dropping:
const onDrop = (editor, e) => {
e.preventDefault()
const file = e.dataTransfer.items[0].getAsFile()
readFile(file)
}
And pass it as the appropriate prop to the component Controlled
:
<Controlled
onDrop={onDrop}
// другие пропы
/>
import { useState, useRef } from 'react'
import { Controlled } from 'react-codemirror2'
import { Button } from './Button'
import { ThemeSelector } from './ThemeSelector'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/xml/xml'
import 'codemirror/mode/css/css'
import 'codemirror/mode/javascript/javascript'
import 'codemirror/addon/edit/closetag'
import 'codemirror/addon/edit/closebrackets'
import 'codemirror/addon/edit/matchtags'
import 'codemirror/addon/edit/matchbrackets'
export const CodeEditor = ({ mode, value, setValue }) => {
const [theme, setTheme] = useState('dracula')
const fileInputRef = useRef()
const changeCode = (editor, data, value) => {
setValue(value)
}
const isFileValid = (file) =>
(mode === 'xml' && file.type === 'text/html') || file.type.includes(mode)
const readFile = (file) => {
if (!isFileValid(file)) return
const reader = new FileReader()
reader.onloadend = () => {
setValue(reader.result)
}
reader.readAsText(file)
}
const loadFile = (e) => {
const file = e.target.files[0]
readFile(file)
}
const onDrop = (editor, e) => {
e.preventDefault()
const file = e.dataTransfer.items[0].getAsFile()
readFile(file)
}
return (
<div className="code-editor">
<ThemeSelector setTheme={setTheme} />
<Button
className="btn file"
title="Load file"
onClick={() => {
fileInputRef.current.click()
}}
/>
<input
type="file"
accept="text/html, text/css, text/javascript"
style={{ display: 'none' }}
aria-hidden='true'
ref={fileInputRef}
onChange={loadFile}
/>
<Controlled
value={value}
onBeforeChange={changeCode}
onDrop={onDrop}
options={{
mode,
theme,
lint: true,
lineNumbers: true,
lineWrapping: true,
spellcheck: true,
autoCloseTags: true,
autoCloseBrackets: true,
matchTags: true,
matchBrackets: true
}}
/>
</div>
)
}
Now we can not only edit the markup, styles and scripts manually, but also upload and drop the corresponding files:
Perhaps this is all I wanted to share with you in this article.
I hope you enjoyed it and don’t regret your time.
Thank you for your attention and happy coding!