Привет, друзья!
В этом небольшом туториале я покажу вам, как разработать простой редактор кода на 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!
Комментарии (2)
Sowjanya
27.01.2022 15:41Indeed a very detailed article and easily understood by readers. I am planning to learn React to upskill myself.
Thanks for sharing!
mSnus
Вот так и выглядит настоящий no-code!