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

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