Привет, друзья!


В этом небольшом туториале я покажу вам, как разработать простой редактор кода на React.


Обратите внимание: туториал рассчитан, преимущественно, на начинающих разработчиков, хотя, смею надеяться, что и опытные найдут в нем что-нибудь интересное для себя.


Функционал нашего приложения будет следующим:


  • имеется три вкладки: для ручного редактирования HTML, CSS и JavaScript, соответственно;
  • пользователь имеет возможность загружать файлы, соответствующие текущей вкладке;
  • пользователь имеет возможность бросать (drop) файлы, соответствующие текущей вкладке;
  • код, введенный пользователем, загружается в iframe и выполняется в режиме песочницы (sandbox) при нажатии соответствующей кнопки.

Песочница:

Репозиторий.


Источник вдохновения.


Если вам это интересно, прошу под кат.


Для разработки редактора мы будем использовать 2 библиотеки:


  • CodeMirror — универсальный текстовый редактор на JavaScript для браузера, предназначенный для редактирования кода, поддерживающий более 100 языков программирования и предоставляющий большое количество различных плагинов (addons) для реализации продвинутого функционала;
  • react-codemirror2 — абстракция над codemirror для react.

Для создания шаблона приложения мы будем использовать Vite.


Для стилизации — Sass.


Для установки зависимостей — Yarn.


Создаем шаблон приложения:


# code-editor - название проекта
# --template react - используемый шаблон
yarn create vite code-editor --template react

Переходим в созданную директорию и устанавливаем codemirror и react-codemirror2 в качестве производственных зависимостей, а также sass в качестве зависимости для разработки:


cd code-editor

yarn add codemirror react-codemirror2

yarn -D sass

Структура директории src будет следующей:


- components
  - Button.jsx - кнопка
  - CodeEditor.jsx - компонент для редактирования кода
  - CodeExecutor.jsx - компонент для выполнения кода
  - Tabs.jsx - компонент для переключения вкладок
  - ThemeSelector.jsx - компонент для выбора темы для редактора кода
- App.jsx
- App.scss
- main.jsx

При выполнении кода react-codemirror2 может возникнуть ошибка ReferenceError: global is not defined. Для того чтобы устранить этот баг, необходимо добавить в index.html такую строку:


<script>
  window.global = window
</script>

Дефолтная обработка события drop в браузере предполагает открытие "брошенного" файла в новой вкладке (см. Drag and Drop API). Мы будем обрабатывать указанное событие самостоятельно, поэтому отключаем его обработку по умолчанию в main.jsx:


window.ondrop = (e) => {
  e.preventDefault()
}

Займемся реализацией компонентов.


Начнем с самого простого — кнопки (components/Button.jsx):


// функция принимает название класса, текст и обработчик нажатия кнопки
export const Button = ({ className, title, onClick }) => (
  <button className={className} onClick={onClick}>
    {title}
  </button>
)

Думаю, здесь все понятно.


Язык, поддерживаемый редактором кода, определяется пропом mode (режим) компонента Controlled из react-codemirror2. Поэтому для переключения вкладки нам достаточно переключить режим редактора. Реализуем эту логику в компоненте Tabs (components/Tabs.jsx):


// импортируем кнопку
import { Button } from './Button'

// определяем массив режимов (они же являются текстами кнопок)
const tabs = ['HTML', 'CSS', 'JS']

// функция принимает режим и метод для его установки
export const Tabs = ({ mode, setMode }) => {
  // TODO
}

Определяем функцию для установки режима:


const changeMode = ({ target: { textContent } }) => {
  // значение режима - текст кнопки в нижнем регистре
  setMode(textContent.toLowerCase())
}

Возвращаем разметку:


return (
  <div className='tabs'>
    {tabs.map((m) => (
      <Button
        key={m}
        title={m}
        onClick={changeMode}
        // индикатор текущей вкладки
        className={m.toLowerCase() === mode ? 'current' : ''}
      />
    ))}
  </div>
)

Тема редактора определяется пропом theme компонента Controlled. Для использования темы достаточно импортировать нужный CSS-файл в код компонента. codemirror предоставляет большой набор готовых тем, демо которых можно посмотреть здесь. Мы возьмем 3 темы: dracula, material и mdn-like. Импортируем темы в компоненте 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
}

Определяем функцию для установки темы:


const selectTheme = ({ target: { value } }) => {
  setTheme(value)
}

Возвращаем разметку:


return (
  <div className='theme-selector'>
    <label htmlFor='theme'>Theme: </label>
    <select id='theme' name='theme' onChange={selectTheme}>
      {themes.map((t) => (
        <option key={t} value={t}>
          {t}
        </option>
      ))}
    </select>
  </div>
)

Определим простейший редактор кода (components/CodeEditor.jsx).


Импортируем хуки, обертку и компоненты:


// хук
import { useState } 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'

// функция принимает режим, код и метод для его изменения
export const CodeEditor = ({ mode, value, setValue }) => {
  // TODO
}

Определяем локальное состояние для темы:


// дефолтной темой является `dracula`
const [theme, setTheme] = useState('dracula')

Определяем функцию для изменения кода:


// нас интересует только последний аргумент, передаваемый функции
const changeCode = (editor, data, value) => {
  setValue(value)
}

Возвращаем разметку:


<div className='code-editor'>
    {/* компонент для выбора темы */}
    <ThemeSelector setTheme={setTheme} />
    {/* обертка */}
    <Controlled
      value={value}
      onBeforeChange={changeCode}
      // настройки
      options={{
        // режим (условно, текущий язык программирования)
        mode,
        // тема
        theme
      }}
      onDrop={onDrop}
    />
  </div>
)

Давайте определим еще несколько настроек, чтобы наш редактор был более user friendly:


options={{
  mode,
  theme,
  // new
  lint: true,
  lineNumbers: true,
  lineWrapping: true,
  spellcheck: true
}}

  • lint: true: включаем "линтинг" кода;
  • lineWrapping: true: при достижении конца строки выполняется перевод на новую строку (это позволяет избежать появления горизонтальной прокрутки);
  • lineNumbers: true: нумерация строк;
  • spellcheck: true: проверка правописания.

С полным списком доступных настроек можно ознакомиться здесь.


Также добавим парочку плагинов. Импортируем их:


import 'codemirror/addon/edit/closetag'
import 'codemirror/addon/edit/closebrackets'
import 'codemirror/addon/edit/matchtags'
import 'codemirror/addon/edit/matchbrackets'

И передаем в настройки:


options={{
  mode,
  theme,
  lint: true,
  lineNumbers: true,
  lineWrapping: true,
  spellcheck: true,
  // new
  autoCloseTags: true,
  autoCloseBrackets: true,
  matchTags: true,
  matchBrackets: true
}}

  • autoCloseTags: true — автоматическое проставление закрывающих HTML-тегов;
  • autoCloseBrackets: true — автоматическое проставление закрывающих скобок;
  • matchTags: true — подсветка парных тегов;
  • matchBrackets: true — подсветка парных скобок.

С полным списком доступных плагинов можно ознакомиться здесь.


Поднимаемся к родительскому компоненту (App.jsx).


Импортируем стили, хук и компоненты:


import './App.scss'
import { useState } from 'react'
import { Tabs } from './components/Tabs'
import { CodeEditor } from './components/CodeEditor'

Определяем начальные значения HTML, CSS и JS для редактора:


// заголовок с текстом `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
}

Определяем локальное состояние для режима, HTML, CSS и JS:


// режимом по умолчанию является `HTML`
const [mode, setMode] = useState('html')
const [html, setHtml] = useState(initialHTML)
const [css, setCss] = useState(initialCSS.trim())
const [js, setJs] = useState(initialJavaScript.trim())

Один нюанс: интересующие нас режимы носят названия xml, css и javascript в codemirror, однако в компонентах мы используем другие названия — html вместо xml и js вместо javascript.


Определяем объект с пропами для редактора:


const propsByMode = {
  html: {
    mode: 'xml',
    value: html,
    setValue: setHtml
  },
  css: {
    mode: 'css',
    value: css,
    setValue: setCss
  },
  js: {
    mode: 'javascript',
    value: js,
    setValue: setJs
  }
}

Ключами объекта являются "локальные" режимы.


Возвращаем разметку:


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;
  }
}

Выполняем команду yarn dev для запуска сервера для разработки и открываем вкладку браузера по адресу http://localhost:3000:





Наш редактор функционирует: мы можем переключаться между вкладками, редактировать код и менять темы.





Но что толку от кода, который нельзя выполнять?


В простейшем случае для выполнения HTML+CSS+JS можно использовать элемент iframe. Нас интересует 2 атрибута этого элемента:


  • srcdoc: позволяет загружать в iframe инлайновый HTML;
  • sandbox: позволяет накладывать ограничения на загружаемый в iframe контент.

Реализуем компонент для выполнения кода (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>
)

Возвращаемся в App.jsx и импортируем CodeExecutor:


import { CodeExecutor } from './components/CodeExecutor'

Определяем локальное состояние для атрибута srcdoc и метод для установки его значения:


const [srcDoc, setSrcDoc] = useState('')

const runCode = () => {
  setSrcDoc(
    `<html>
      <style>${css}</style>
      <body>${html}</body>
      <script>${js}</script>
    </html>`
  )
}

Передаем соответствующие пропы компоненту CodeExecutor:


<CodeExecutor srcDoc={srcDoc} runCode={runCode} />

Возвращаемся в браузер:





Нажимаем на кнопку Run code:





Видим, что наш код благополучно выполняется. Круто!


Осталось реализовать загрузку и бросание файлов.


Загрузку файлов мы реализуем с помощью скрытого инпута и нескольких методов в компоненте CodeEditor.


Определяем иммутабельную переменную для хранения ссылки на инпут:


const fileInputRef = useRef()

Определяем метод для проверки валидности файла, т.е. того, что загружаемый файл соответствует текущему режиму-вкладке:


const isFileValid = (file) =>
  (mode === 'xml' && file.type === 'text/html') || file.type.includes(mode)

Определяем метод для чтения файла как текста с помощью FileReader:


const readFile = (file) => {
  if (!isFileValid(file)) return

  // создаем экземпляр `FileReader`
  const reader = new FileReader()

  // обрабатываем чтение файла
  reader.onloadend = () => {
    // обновляем значение кода
    setValue(reader.result)
  }

  // читаем файл как текст
  reader.readAsText(file)
}

Определяем метод для загрузки файла:


const loadFile = (e) => {
  const file = e.target.files[0]

  readFile(file)
}

Добавляем в разметку кнопку и инпут:


<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}
/>

Аналогичным образом определяем функцию для обработки бросания файла:


 const onDrop = (editor, e) => {
  e.preventDefault()

  const file = e.dataTransfer.items[0].getAsFile()

  readFile(file)
}

И передаем ее в качестве соответствующего пропа компоненту Controlled:


<Controlled
  onDrop={onDrop}
  // другие пропы
/>

Полный код компонента `CodeEditor`:
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>
  )
}

Теперь мы имеем возможность не только редактировать разметку, стили и скрипты вручную, но также загружать и бросать соответствующие файлы:





Пожалуй, это все, чем я хотел поделиться с вами в данной статье.


Надеюсь, вам было интересно и вы не жалеете потраченного времени.


Благодарю за внимание и happy coding!




Комментарии (2)


  1. mSnus
    27.01.2022 09:58

    Вот так и выглядит настоящий no-code!


  1. Sowjanya
    27.01.2022 15:41

    Indeed a very detailed article and easily understood by readers. I am planning to learn React to upskill myself.

    Thanks for sharing!