React: Code Editor

от автора

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

В этом небольшом туториале я покажу вам, как разработать простой редактор кода на 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!



ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/648041/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *