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 and JavaScriptrespectively;
  • 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.

repository.

Source of inspiration.

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 for react.

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 codemirrorhowever 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>
)

Styles:

$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 to iframe inline HTML;
  • sandbox: allows you to impose restrictions on what is loaded in iframe 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}
  // другие пропы
/>

The complete code of the `CodeEditor` component:

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!


Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *