Онлайн-платформа выполнения кода позволяет писать и сразу запускать код на любимом языке программирования. В идеале можно увидеть вывод программы, например двоичного поиска на JavaScript.
Создадим функциональный редактор кода Monaco Editor. Вот его возможности:
Компонент редактора кода состоит из Monaco Editor, то есть настраиваемого NPM-пакета:
// CodeEditorWindow.js
import React, { useState } from "react";
import Editor from "@monaco-editor/react";
const CodeEditorWindow = ({ onChange, language, code, theme }) => {
const [value, setValue] = useState(code || "");
const handleEditorChange = (value) => {
setValue(value);
onChange("code", value);
};
return (
<div className="overlay rounded-md overflow-hidden w-full h-full shadow-4xl">
<Editor
height="85vh"
width={`100%`}
language={language || "javascript"}
value={value}
theme={theme}
defaultValue="// some comment"
onChange={handleEditorChange}
/>
</div>
);
};
export default CodeEditorWindow;
Компоненты Editor берутся из пакета @monaco-editor/react, который позволяет развернуть редактор кода с соответствующей высотой области просмотра 85vh.
Компонент Editor принимает много свойств:
import React, { useEffect, useState } from "react";
import CodeEditorWindow from "./CodeEditorWindow";
import axios from "axios";
import { classnames } from "../utils/general";
import { languageOptions } from "../constants/languageOptions";
import { ToastContainer, toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { defineTheme } from "../lib/defineTheme";
import useKeyPress from "../hooks/useKeyPress";
import Footer from "./Footer";
import OutputWindow from "./OutputWindow";
import CustomInput from "./CustomInput";
import OutputDetails from "./OutputDetails";
import ThemeDropdown from "./ThemeDropdown";
import LanguagesDropdown from "./LanguagesDropdown";
const javascriptDefault = `// some comment`;
const Landing = () => {
const [code, setCode] = useState(javascriptDefault);
const [customInput, setCustomInput] = useState("");
const [outputDetails, setOutputDetails] = useState(null);
const [processing, setProcessing] = useState(null);
const [theme, setTheme] = useState("cobalt");
const [language, setLanguage] = useState(languageOptions[0]);
const enterPress = useKeyPress("Enter");
const ctrlPress = useKeyPress("Control");
const onSelectChange = (sl) => {
console.log("selected Option...", sl);
setLanguage(sl);
};
useEffect(() => {
if (enterPress && ctrlPress) {
console.log("enterPress", enterPress);
console.log("ctrlPress", ctrlPress);
handleCompile();
}
}, [ctrlPress, enterPress]);
const onChange = (action, data) => {
switch (action) {
case "code": {
setCode(data);
break;
}
default: {
console.warn("case not handled!", action, data);
}
}
};
const handleCompile = () => {
// We will come to the implementation later in the code
};
const checkStatus = async (token) => {
// We will come to the implementation later in the code
};
function handleThemeChange(th) {
// We will come to the implementation later in the code
}
useEffect(() => {
defineTheme("oceanic-next").then((_) =>
setTheme({ value: "oceanic-next", label: "Oceanic Next" })
);
}, []);
const showSuccessToast = (msg) => {
toast.success(msg || `Compiled Successfully!`, {
position: "top-right",
autoClose: 1000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
});
};
const showErrorToast = (msg) => {
toast.error(msg || `Something went wrong! Please try again.`, {
position: "top-right",
autoClose: 1000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
});
};
return (
<>
<ToastContainer
position="top-right"
autoClose={2000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
/>
<div className="h-4 w-full bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500"></div>
<div className="flex flex-row">
<div className="px-4 py-2">
<LanguagesDropdown onSelectChange={onSelectChange} />
</div>
<div className="px-4 py-2">
<ThemeDropdown handleThemeChange={handleThemeChange} theme={theme} />
</div>
</div>
<div className="flex flex-row space-x-4 items-start px-4 py-4">
<div className="flex flex-col w-full h-full justify-start items-end">
<CodeEditorWindow
code={code}
onChange={onChange}
language={language?.value}
theme={theme.value}
/>
</div>
<div className="right-container flex flex-shrink-0 w-[30%] flex-col">
<OutputWindow outputDetails={outputDetails} />
<div className="flex flex-col items-end">
<CustomInput
customInput={customInput}
setCustomInput={setCustomInput}
/>
<button
onClick={handleCompile}
disabled={!code}
className={classnames(
"mt-4 border-2 border-black z-10 rounded-md shadow-[5px_5px_0px_0px_rgba(0,0,0)] px-4 py-2 hover:shadow transition duration-200 bg-white flex-shrink-0",
!code ? "opacity-50" : ""
)}
>
{processing ? "Processing..." : "Compile and Execute"}
</button>
</div>
{outputDetails && <OutputDetails outputDetails={outputDetails} />}
</div>
</div>
<Footer />
</>
);
};
export default Landing;
Рассмотрим базовую структуру Landing подробнее.
// onChange method implementation
const onChange = (action, data) => {
switch (action) {
case "code": {
setCode(data);
break;
}
default: {
console.warn("case not handled!", action, data);
}
}
};
Задаём состояние code и отслеживаем изменения.
В компоненте CodeEditorWindow также учитывается свойство language — выбранный в данный момент язык, для которого нужны подсветка синтаксиса и автодополнение ввода.
Массив languageOptions я создал для отслеживания принятых в Monaco Editor свойств языка, а также для работы с компиляцией (отслеживаем languageId, принимаемый в этих API judge0):
// constants/languageOptions.js
export const languageOptions = [
{
id: 63,
name: "JavaScript (Node.js 12.14.0)",
label: "JavaScript (Node.js 12.14.0)",
value: "javascript",
},
{
id: 45,
name: "Assembly (NASM 2.14.02)",
label: "Assembly (NASM 2.14.02)",
value: "assembly",
},
...
...
...
...
...
...
{
id: 84,
name: "Visual Basic.Net (vbnc 0.0.0.5943)",
label: "Visual Basic.Net (vbnc 0.0.0.5943)",
value: "vbnet",
},
];
В каждом объекте languageOptions есть свойства id, name, label и value. Массив languageOptions помещается в выпадающий список и предоставляются как его варианты.
Когда состояние выпадающего списка меняется, в методе onSelectChange отслеживается выбранный id с соответствующим изменением состояния.
// LanguageDropdown.js
import React from "react";
import Select from "react-select";
import { customStyles } from "../constants/customStyles";
import { languageOptions } from "../constants/languageOptions";
const LanguagesDropdown = ({ onSelectChange }) => {
return (
<Select
placeholder={`Filter By Category`}
options={languageOptions}
styles={customStyles}
defaultValue={languageOptions[0]}
onChange={(selectedOption) => onSelectChange(selectedOption)}
/>
);
};
export default LanguagesDropdown;
Для выпадающих списков и их обработчиков изменений используется пакет react-select.
Основные параметры react-select — defaultValue и массив options (здесь будем передавать languageOptions), с помощью которого автоматически отображаются все эти значения выпадающего списка.
Свойство defaultValue — это указываемое в компоненте значение по умолчанию. Языком по умолчанию оставим первый язык в массиве языков — JavaScript.
Когда пользователь меняет язык, это происходит с помощью onSelectChange:
const onSelectChange = (sl) => {
setLanguage(sl);
};
Компонент ThemeDropdown очень похож на LanguageDropdown (с пользовательским интерфейсом и пакетом react-select):
// ThemeDropdown.js
import React from "react";
import Select from "react-select";
import monacoThemes from "monaco-themes/themes/themelist";
import { customStyles } from "../constants/customStyles";
const ThemeDropdown = ({ handleThemeChange, theme }) => {
return (
<Select
placeholder={`Select Theme`}
// options={languageOptions}
options={Object.entries(monacoThemes).map(([themeId, themeName]) => ({
label: themeName,
value: themeId,
key: themeId,
}))}
value={theme}
styles={customStyles}
onChange={handleThemeChange}
/>
);
};
export default ThemeDropdown;
Здесь для выбора красивых тем из списка ниже, доступных Monaco Editor, используем пакет monacoThemes:
// lib/defineTheme.js
import { loader } from "@monaco-editor/react";
const monacoThemes = {
active4d: "Active4D",
"all-hallows-eve": "All Hallows Eve",
amy: "Amy",
"birds-of-paradise": "Birds of Paradise",
blackboard: "Blackboard",
"brilliance-black": "Brilliance Black",
"brilliance-dull": "Brilliance Dull",
"chrome-devtools": "Chrome DevTools",
"clouds-midnight": "Clouds Midnight",
clouds: "Clouds",
cobalt: "Cobalt",
dawn: "Dawn",
dreamweaver: "Dreamweaver",
eiffel: "Eiffel",
"espresso-libre": "Espresso Libre",
github: "GitHub",
idle: "IDLE",
katzenmilch: "Katzenmilch",
"kuroir-theme": "Kuroir Theme",
lazy: "LAZY",
"magicwb--amiga-": "MagicWB (Amiga)",
"merbivore-soft": "Merbivore Soft",
merbivore: "Merbivore",
"monokai-bright": "Monokai Bright",
monokai: "Monokai",
"night-owl": "Night Owl",
"oceanic-next": "Oceanic Next",
"pastels-on-dark": "Pastels on Dark",
"slush-and-poppies": "Slush and Poppies",
"solarized-dark": "Solarized-dark",
"solarized-light": "Solarized-light",
spacecadet: "SpaceCadet",
sunburst: "Sunburst",
"textmate--mac-classic-": "Textmate (Mac Classic)",
"tomorrow-night-blue": "Tomorrow-Night-Blue",
"tomorrow-night-bright": "Tomorrow-Night-Bright",
"tomorrow-night-eighties": "Tomorrow-Night-Eighties",
"tomorrow-night": "Tomorrow-Night",
tomorrow: "Tomorrow",
twilight: "Twilight",
"upstream-sunburst": "Upstream Sunburst",
"vibrant-ink": "Vibrant Ink",
"xcode-default": "Xcode_default",
zenburnesque: "Zenburnesque",
iplastic: "iPlastic",
idlefingers: "idleFingers",
krtheme: "krTheme",
monoindustrial: "monoindustrial",
};
const defineTheme = (theme) => {
return new Promise((res) => {
Promise.all([
loader.init(),
import(`monaco-themes/themes/${monacoThemes[theme]}.json`),
]).then(([monaco, themeData]) => {
monaco.editor.defineTheme(theme, themeData);
res();
});
});
};
export { defineTheme };
В monaco-themes тем много, так что внешний вид будущего редактора — не проблема.
Темы выбирает функция defineTheme, в ней возвращается промис, посредством которого с помощью экшена monaco.editor.defineTheme(theme, themeData) задаётся тема редактора. Само изменение тем внутри окна кода Monaco Editor происходит в этой строке кода.
Функция defineTheme вызывается с помощью обратного вызова onChange, который мы уже видели в компоненте ThemeDropdown.js:
// Landing.js - handleThemeChange() function
function handleThemeChange(th) {
const theme = th;
console.log("theme...", theme);
if (["light", "vs-dark"].includes(theme.value)) {
setTheme(theme);
} else {
defineTheme(theme.value).then((_) => setTheme(theme));
}
}
В функции handleThemeChange() проверяется тема: light (светлая) или dark (тёмная). Эти темы по умолчанию доступны в компоненте MonacoEditor — вызывать метод defineTheme() не нужно.
Если тем в списке нет, вызываем компонент defineTheme() и задаём состояние выбранной темы.
Выполнить вызов API можно с произвольными параметрами (исходный код, идентификатор языка) и получить в ответ выходные данные.
Настраиваем Judge0:
Для вызовов API нужны параметры X-RapidAPI-Host и X-RapidAPI-Key. Сохраните их в файлах .env:
REACT_APP_RAPID_API_HOST = YOUR_HOST_URL
REACT_APP_RAPID_API_KEY = YOUR_SECRET_KEY
REACT_APP_RAPID_API_URL = YOUR_SUBMISSIONS_URL
В React важно инициализировать переменные окружения с префиксом REACT_APP.
Будем использовать URL-адрес SUBMISSIONS_URL из хоста и маршрута /submission.
Например, https://judge0-ce.p.rapidapi.com/submissions будет URL-адресом submissions в нашем случае.
После настройки переменных переходим к логике компиляции.
const handleCompile = () => {
setProcessing(true);
const formData = {
language_id: language.id,
// encode source code in base64
source_code: btoa(code),
stdin: btoa(customInput),
};
const options = {
method: "POST",
url: process.env.REACT_APP_RAPID_API_URL,
params: { base64_encoded: "true", fields: "*" },
headers: {
"content-type": "application/json",
"Content-Type": "application/json",
"X-RapidAPI-Host": process.env.REACT_APP_RAPID_API_HOST,
"X-RapidAPI-Key": process.env.REACT_APP_RAPID_API_KEY,
},
data: formData,
};
axios
.request(options)
.then(function (response) {
console.log("res.data", response.data);
const token = response.data.token;
checkStatus(token);
})
.catch((err) => {
let error = err.response ? err.response.data : err;
setProcessing(false);
console.log(error);
});
};
Он принимает languageId, source_code и stdin. Обратите внимание на btoa перед source_code и stdin. Это нужно для кодирования строк в формате base64, потому что у нас в параметрах запроса к API есть base64_encoded: true.
Если получен успешный ответ и есть token, вызываем метод checkStatus() для опроса маршрута /submissions/${token}:
const checkStatus = async (token) => {
const options = {
method: "GET",
url: process.env.REACT_APP_RAPID_API_URL + "/" + token,
params: { base64_encoded: "true", fields: "*" },
headers: {
"X-RapidAPI-Host": process.env.REACT_APP_RAPID_API_HOST,
"X-RapidAPI-Key": process.env.REACT_APP_RAPID_API_KEY,
},
};
try {
let response = await axios.request(options);
let statusId = response.data.status?.id;
// Processed - we have a result
if (statusId === 1 || statusId === 2) {
// still processing
setTimeout(() => {
checkStatus(token)
}, 2000)
return
} else {
setProcessing(false)
setOutputDetails(response.data)
showSuccessToast(`Compiled Successfully!`)
console.log('response.data', response.data)
return
}
} catch (err) {
console.log("err", err);
setProcessing(false);
showErrorToast();
}
};
Чтобы получить результаты отправленного ранее кода, нужно опросить submissions с помощью token из ответа. Для этого выполняем GET-запрос к конечной точке. После получения ответа проверяем statusId === 1 || statusId === 2. Но что это значит? У нас 14 статусов, связанных с любой отправляемой в API частью кода:
export const statuses = [
{
id: 1,
description: "In Queue",
},
{
id: 2,
description: "Processing",
},
{
id: 3,
description: "Accepted",
},
{
id: 4,
description: "Wrong Answer",
},
{
id: 5,
description: "Time Limit Exceeded",
},
{
id: 6,
description: "Compilation Error",
},
{
id: 7,
description: "Runtime Error (SIGSEGV)",
},
{
id: 8,
description: "Runtime Error (SIGXFSZ)",
},
{
id: 9,
description: "Runtime Error (SIGFPE)",
},
{
id: 10,
description: "Runtime Error (SIGABRT)",
},
{
id: 11,
description: "Runtime Error (NZEC)",
},
{
id: 12,
description: "Runtime Error (Other)",
},
{
id: 13,
description: "Internal Error",
},
{
id: 14,
description: "Exec Format Error",
},
];
Если statusId === 1 или statusId === 2, код обрабатывается, и нужно снова вызвать API и проверить, получен ли результат. Из-за этого в if прописан setTimeout(), где снова вызывается функция checkStatus(), а внутри неё снова вызывается API и проверяется статус.
Если статус не 2 или 3, выполнение кода завершено и есть результат — успешно скомпилированный код или код с превышением предела времени компиляции. А может, код с исключением времени выполнения; statusId представляет все ситуации, которые тоже можно воспроизвести.
Например, в while(true) выдаётся ошибка превышения предела времени:
Или, если допущена ошибка синтаксиса, вернётся ошибка компиляции:
Так или иначе, есть результат, который сохраняется в состоянии outputDetails, чтобы было что отображать в окне вывода, в правой части экрана.
import React from "react";
const OutputWindow = ({ outputDetails }) => {
const getOutput = () => {
let statusId = outputDetails?.status?.id;
if (statusId === 6) {
// compilation error
return (
<pre className="px-2 py-1 font-normal text-xs text-red-500">
{atob(outputDetails?.compile_output)}
</pre>
);
} else if (statusId === 3) {
return (
<pre className="px-2 py-1 font-normal text-xs text-green-500">
{atob(outputDetails.stdout) !== null
? `${atob(outputDetails.stdout)}`
: null}
</pre>
);
} else if (statusId === 5) {
return (
<pre className="px-2 py-1 font-normal text-xs text-red-500">
{`Time Limit Exceeded`}
</pre>
);
} else {
return (
<pre className="px-2 py-1 font-normal text-xs text-red-500">
{atob(outputDetails?.stderr)}
</pre>
);
}
};
return (
<>
<h1 className="font-bold text-xl bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-700 mb-2">
Output
</h1>
<div className="w-full h-56 bg-[#1e293b] rounded-md text-white font-normal text-sm overflow-y-auto">
{outputDetails ? <>{getOutput()}</> : null}
</div>
</>
);
};
export default OutputWindow;
Это простой компонент для отображения успеха или неуспеха компиляции. В методе getOutput() определяются вывод и цвет текста.
Компонент OutputDetails — это простой модуль сопоставления для вывода данных, связанных с изначально скомпилированным фрагментом кода. Данные уже заданы в переменной состояния outputDetails:
import React from "react";
const OutputDetails = ({ outputDetails }) => {
return (
<div className="metrics-container mt-4 flex flex-col space-y-3">
<p className="text-sm">
Status:{" "}
<span className="font-semibold px-2 py-1 rounded-md bg-gray-100">
{outputDetails?.status?.description}
</span>
</p>
<p className="text-sm">
Memory:{" "}
<span className="font-semibold px-2 py-1 rounded-md bg-gray-100">
{outputDetails?.memory}
</span>
</p>
<p className="text-sm">
Time:{" "}
<span className="font-semibold px-2 py-1 rounded-md bg-gray-100">
{outputDetails?.time}
</span>
</p>
</div>
);
};
export default OutputDetails;
time, memory и status.description читаются из ответа от API, а затем сохраняются в outputDetails и отображаются.
// useKeyPress.js
import React, { useState } from "react";
const useKeyPress = function (targetKey) {
const [keyPressed, setKeyPressed] = useState(false);
function downHandler({ key }) {
if (key === targetKey) {
setKeyPressed(true);
}
}
const upHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
React.useEffect(() => {
document.addEventListener("keydown", downHandler);
document.addEventListener("keyup", upHandler);
return () => {
document.removeEventListener("keydown", downHandler);
document.removeEventListener("keyup", upHandler);
};
});
return keyPressed;
};
export default useKeyPress;
// Landing.js
...
...
...
const Landing = () => {
...
...
const enterPress = useKeyPress("Enter");
const ctrlPress = useKeyPress("Control");
...
...
}
Здесь для прослушивания целевой клавиши нужны нативные прослушиватели событий JavaScript. События keydown и keyup прослушиваются с помощью хука. Хук инициализируется целевой клавишей Enter и Control. Проверяется targetKey === key и, соответственно, задаётся keyPressed, поэтому можно использовать возвращаемое логическое значение keyPressed — true или false.
Теперь можно прослушать эти события в хуке useEffect и убедиться, что обе клавиши нажаты одновременно:
useEffect(() => {
if (enterPress && ctrlPress) {
console.log("enterPress", enterPress);
console.log("ctrlPress", ctrlPress);
handleCompile();
}
}, [ctrlPress, enterPress]);
Метод handleCompile() вызывается, когда пользователь нажимает Ctrl и Enter последовательно или одновременно.
Демонстрации
Создадим функциональный редактор кода Monaco Editor. Вот его возможности:
- поддержка VS Code;
- компиляция в веб-приложении со стандартным вводом и выводом и поддержкой более чем 40 языков;
- выбор темы редактора из списка доступных тем;
- информация о коде (время выполнения, используемая память, статус и т. д.).
Технологический стек
- React.js для фронтенда;
- TailwindCSS для стилей;
- Judge0 для компиляции и выполнения кода;
- RapidAPI для быстрого развёртывания кода Judge0;
- Monaco Editor — редактор кода для проекта.
Структура проекта
Структура проекта проста:- сomponents: компоненты / сниппеты кода (например, CodeEditorWindow и Landing);
- hooks: пользовательские хуки (и хуки нажатия клавиш — для компилирования кода с помощью событий клавиатуры);
- lib: библиотечные функции (здесь создадим функцию определения темы);
- constants: константы, такие как languageOptions и customStyles, для выпадающих списков;
- utils: служебные функции для сопровождения кода.
Логика работы с приложением
Прежде чем переходить к коду, разберёмся в логике работы с приложением и в том, как писать для него код с нуля.- Пользователь попадает в веб-приложение и выбирает язык (по умолчанию — JavaScript).
- После написания кода пользователь его компилирует, а выходные данные просматривает в окне вывода.
- В окне вывода кода вы увидите вывод и статус кода.
- Пользователь может добавлять к фрагментам кода свои входные данные, которые учитываются в judge (онлайн-компиляторе).
- Пользователь может видеть информацию о выполненном коде (пример: на компиляцию и выполнение ушло 5 мс, использовано 2024 Кб памяти, выполнение кода завершено успешно).
Как создать компонент редактора кода
Компонент редактора кода состоит из Monaco Editor, то есть настраиваемого NPM-пакета:
// CodeEditorWindow.js
import React, { useState } from "react";
import Editor from "@monaco-editor/react";
const CodeEditorWindow = ({ onChange, language, code, theme }) => {
const [value, setValue] = useState(code || "");
const handleEditorChange = (value) => {
setValue(value);
onChange("code", value);
};
return (
<div className="overlay rounded-md overflow-hidden w-full h-full shadow-4xl">
<Editor
height="85vh"
width={`100%`}
language={language || "javascript"}
value={value}
theme={theme}
defaultValue="// some comment"
onChange={handleEditorChange}
/>
</div>
);
};
export default CodeEditorWindow;
Компоненты Editor берутся из пакета @monaco-editor/react, который позволяет развернуть редактор кода с соответствующей высотой области просмотра 85vh.
Компонент Editor принимает много свойств:
- language: язык, для которого нужны подсветка синтаксиса и автодополнение ввода.
- theme: цвета и фон фрагмента кода (настроим позже).
- value: код, который вводится в редактор.
- onChange: происходит при изменении value в редакторе. Изменившееся значение нужно сохранить в состоянии, чтобы позже для компиляции вызвать API Judge0.
Как создать компонент Landing
Компонент landing в состоит из трёх частей:- Actions Bar с компонентами выпадающих списков Languages и Themes.
- Компонент Code Editor Window.
- Компоненты Output и Custom Input.
import React, { useEffect, useState } from "react";
import CodeEditorWindow from "./CodeEditorWindow";
import axios from "axios";
import { classnames } from "../utils/general";
import { languageOptions } from "../constants/languageOptions";
import { ToastContainer, toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { defineTheme } from "../lib/defineTheme";
import useKeyPress from "../hooks/useKeyPress";
import Footer from "./Footer";
import OutputWindow from "./OutputWindow";
import CustomInput from "./CustomInput";
import OutputDetails from "./OutputDetails";
import ThemeDropdown from "./ThemeDropdown";
import LanguagesDropdown from "./LanguagesDropdown";
const javascriptDefault = `// some comment`;
const Landing = () => {
const [code, setCode] = useState(javascriptDefault);
const [customInput, setCustomInput] = useState("");
const [outputDetails, setOutputDetails] = useState(null);
const [processing, setProcessing] = useState(null);
const [theme, setTheme] = useState("cobalt");
const [language, setLanguage] = useState(languageOptions[0]);
const enterPress = useKeyPress("Enter");
const ctrlPress = useKeyPress("Control");
const onSelectChange = (sl) => {
console.log("selected Option...", sl);
setLanguage(sl);
};
useEffect(() => {
if (enterPress && ctrlPress) {
console.log("enterPress", enterPress);
console.log("ctrlPress", ctrlPress);
handleCompile();
}
}, [ctrlPress, enterPress]);
const onChange = (action, data) => {
switch (action) {
case "code": {
setCode(data);
break;
}
default: {
console.warn("case not handled!", action, data);
}
}
};
const handleCompile = () => {
// We will come to the implementation later in the code
};
const checkStatus = async (token) => {
// We will come to the implementation later in the code
};
function handleThemeChange(th) {
// We will come to the implementation later in the code
}
useEffect(() => {
defineTheme("oceanic-next").then((_) =>
setTheme({ value: "oceanic-next", label: "Oceanic Next" })
);
}, []);
const showSuccessToast = (msg) => {
toast.success(msg || `Compiled Successfully!`, {
position: "top-right",
autoClose: 1000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
});
};
const showErrorToast = (msg) => {
toast.error(msg || `Something went wrong! Please try again.`, {
position: "top-right",
autoClose: 1000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
});
};
return (
<>
<ToastContainer
position="top-right"
autoClose={2000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
/>
<div className="h-4 w-full bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500"></div>
<div className="flex flex-row">
<div className="px-4 py-2">
<LanguagesDropdown onSelectChange={onSelectChange} />
</div>
<div className="px-4 py-2">
<ThemeDropdown handleThemeChange={handleThemeChange} theme={theme} />
</div>
</div>
<div className="flex flex-row space-x-4 items-start px-4 py-4">
<div className="flex flex-col w-full h-full justify-start items-end">
<CodeEditorWindow
code={code}
onChange={onChange}
language={language?.value}
theme={theme.value}
/>
</div>
<div className="right-container flex flex-shrink-0 w-[30%] flex-col">
<OutputWindow outputDetails={outputDetails} />
<div className="flex flex-col items-end">
<CustomInput
customInput={customInput}
setCustomInput={setCustomInput}
/>
<button
onClick={handleCompile}
disabled={!code}
className={classnames(
"mt-4 border-2 border-black z-10 rounded-md shadow-[5px_5px_0px_0px_rgba(0,0,0)] px-4 py-2 hover:shadow transition duration-200 bg-white flex-shrink-0",
!code ? "opacity-50" : ""
)}
>
{processing ? "Processing..." : "Compile and Execute"}
</button>
</div>
{outputDetails && <OutputDetails outputDetails={outputDetails} />}
</div>
</div>
<Footer />
</>
);
};
export default Landing;
Рассмотрим базовую структуру Landing подробнее.
Компонент CodeEditorWindow
Как мы уже видели, в компоненте CodeEditorWindow учитываются постоянно меняющийся код и метод onChange, с помощью которого отслеживаются изменения в код:.// onChange method implementation
const onChange = (action, data) => {
switch (action) {
case "code": {
setCode(data);
break;
}
default: {
console.warn("case not handled!", action, data);
}
}
};
Задаём состояние code и отслеживаем изменения.
В компоненте CodeEditorWindow также учитывается свойство language — выбранный в данный момент язык, для которого нужны подсветка синтаксиса и автодополнение ввода.
Массив languageOptions я создал для отслеживания принятых в Monaco Editor свойств языка, а также для работы с компиляцией (отслеживаем languageId, принимаемый в этих API judge0):
// constants/languageOptions.js
export const languageOptions = [
{
id: 63,
name: "JavaScript (Node.js 12.14.0)",
label: "JavaScript (Node.js 12.14.0)",
value: "javascript",
},
{
id: 45,
name: "Assembly (NASM 2.14.02)",
label: "Assembly (NASM 2.14.02)",
value: "assembly",
},
...
...
...
...
...
...
{
id: 84,
name: "Visual Basic.Net (vbnc 0.0.0.5943)",
label: "Visual Basic.Net (vbnc 0.0.0.5943)",
value: "vbnet",
},
];
В каждом объекте languageOptions есть свойства id, name, label и value. Массив languageOptions помещается в выпадающий список и предоставляются как его варианты.
Когда состояние выпадающего списка меняется, в методе onSelectChange отслеживается выбранный id с соответствующим изменением состояния.
Компонент LanguageDropdown
import React from "react";
import Select from "react-select";
import { customStyles } from "../constants/customStyles";
import { languageOptions } from "../constants/languageOptions";
const LanguagesDropdown = ({ onSelectChange }) => {
return (
<Select
placeholder={`Filter By Category`}
options={languageOptions}
styles={customStyles}
defaultValue={languageOptions[0]}
onChange={(selectedOption) => onSelectChange(selectedOption)}
/>
);
};
export default LanguagesDropdown;
Для выпадающих списков и их обработчиков изменений используется пакет react-select.
Основные параметры react-select — defaultValue и массив options (здесь будем передавать languageOptions), с помощью которого автоматически отображаются все эти значения выпадающего списка.
Свойство defaultValue — это указываемое в компоненте значение по умолчанию. Языком по умолчанию оставим первый язык в массиве языков — JavaScript.
Когда пользователь меняет язык, это происходит с помощью onSelectChange:
const onSelectChange = (sl) => {
setLanguage(sl);
};
Компонент ThemeDropdown
Компонент ThemeDropdown очень похож на LanguageDropdown (с пользовательским интерфейсом и пакетом react-select):
// ThemeDropdown.js
import React from "react";
import Select from "react-select";
import monacoThemes from "monaco-themes/themes/themelist";
import { customStyles } from "../constants/customStyles";
const ThemeDropdown = ({ handleThemeChange, theme }) => {
return (
<Select
placeholder={`Select Theme`}
// options={languageOptions}
options={Object.entries(monacoThemes).map(([themeId, themeName]) => ({
label: themeName,
value: themeId,
key: themeId,
}))}
value={theme}
styles={customStyles}
onChange={handleThemeChange}
/>
);
};
export default ThemeDropdown;
Здесь для выбора красивых тем из списка ниже, доступных Monaco Editor, используем пакет monacoThemes:
// lib/defineTheme.js
import { loader } from "@monaco-editor/react";
const monacoThemes = {
active4d: "Active4D",
"all-hallows-eve": "All Hallows Eve",
amy: "Amy",
"birds-of-paradise": "Birds of Paradise",
blackboard: "Blackboard",
"brilliance-black": "Brilliance Black",
"brilliance-dull": "Brilliance Dull",
"chrome-devtools": "Chrome DevTools",
"clouds-midnight": "Clouds Midnight",
clouds: "Clouds",
cobalt: "Cobalt",
dawn: "Dawn",
dreamweaver: "Dreamweaver",
eiffel: "Eiffel",
"espresso-libre": "Espresso Libre",
github: "GitHub",
idle: "IDLE",
katzenmilch: "Katzenmilch",
"kuroir-theme": "Kuroir Theme",
lazy: "LAZY",
"magicwb--amiga-": "MagicWB (Amiga)",
"merbivore-soft": "Merbivore Soft",
merbivore: "Merbivore",
"monokai-bright": "Monokai Bright",
monokai: "Monokai",
"night-owl": "Night Owl",
"oceanic-next": "Oceanic Next",
"pastels-on-dark": "Pastels on Dark",
"slush-and-poppies": "Slush and Poppies",
"solarized-dark": "Solarized-dark",
"solarized-light": "Solarized-light",
spacecadet: "SpaceCadet",
sunburst: "Sunburst",
"textmate--mac-classic-": "Textmate (Mac Classic)",
"tomorrow-night-blue": "Tomorrow-Night-Blue",
"tomorrow-night-bright": "Tomorrow-Night-Bright",
"tomorrow-night-eighties": "Tomorrow-Night-Eighties",
"tomorrow-night": "Tomorrow-Night",
tomorrow: "Tomorrow",
twilight: "Twilight",
"upstream-sunburst": "Upstream Sunburst",
"vibrant-ink": "Vibrant Ink",
"xcode-default": "Xcode_default",
zenburnesque: "Zenburnesque",
iplastic: "iPlastic",
idlefingers: "idleFingers",
krtheme: "krTheme",
monoindustrial: "monoindustrial",
};
const defineTheme = (theme) => {
return new Promise((res) => {
Promise.all([
loader.init(),
import(`monaco-themes/themes/${monacoThemes[theme]}.json`),
]).then(([monaco, themeData]) => {
monaco.editor.defineTheme(theme, themeData);
res();
});
});
};
export { defineTheme };
В monaco-themes тем много, так что внешний вид будущего редактора — не проблема.
Темы выбирает функция defineTheme, в ней возвращается промис, посредством которого с помощью экшена monaco.editor.defineTheme(theme, themeData) задаётся тема редактора. Само изменение тем внутри окна кода Monaco Editor происходит в этой строке кода.
Функция defineTheme вызывается с помощью обратного вызова onChange, который мы уже видели в компоненте ThemeDropdown.js:
// Landing.js - handleThemeChange() function
function handleThemeChange(th) {
const theme = th;
console.log("theme...", theme);
if (["light", "vs-dark"].includes(theme.value)) {
setTheme(theme);
} else {
defineTheme(theme.value).then((_) => setTheme(theme));
}
}
В функции handleThemeChange() проверяется тема: light (светлая) или dark (тёмная). Эти темы по умолчанию доступны в компоненте MonacoEditor — вызывать метод defineTheme() не нужно.
Если тем в списке нет, вызываем компонент defineTheme() и задаём состояние выбранной темы.
Как компилировать код с помощью Judge0
Перейдём к самой «вкусной» части приложения — компиляции кода на разных языках, для которой используем Judge0 — интерактивную систему выполнения кода.Выполнить вызов API можно с произвольными параметрами (исходный код, идентификатор языка) и получить в ответ выходные данные.
Настраиваем Judge0:
- переходим к Judge0 и выбираем базовый план;
- на самом деле Judge0 размещён на RapidAPI (идём дальше и подписываемся на базовый план);
- после этого можно скопировать RAPIDAPI_HOST и RAPIDAPI_KEY (для выполнения вызовов API в систему выполнения кода).
Для вызовов API нужны параметры X-RapidAPI-Host и X-RapidAPI-Key. Сохраните их в файлах .env:
REACT_APP_RAPID_API_HOST = YOUR_HOST_URL
REACT_APP_RAPID_API_KEY = YOUR_SECRET_KEY
REACT_APP_RAPID_API_URL = YOUR_SUBMISSIONS_URL
В React важно инициализировать переменные окружения с префиксом REACT_APP.
Будем использовать URL-адрес SUBMISSIONS_URL из хоста и маршрута /submission.
Например, https://judge0-ce.p.rapidapi.com/submissions будет URL-адресом submissions в нашем случае.
После настройки переменных переходим к логике компиляции.
Логика и последовательность компиляции
Последовательность компиляции следующая:- Нажатие кнопки Compile and Execute вызывает метод handleCompile().
- В функции handleCompile() вызывается бэкенд Judge0 RapidAPI по URL-адресу submissions с указанием в качестве параметров запроса — languageId, source_code и stdin — в нашем случае customInput.
- В options как заголовки также принимаются host и secret.
- Могут передаваться дополнительные параметры base64_encoded и fields.
- При отправке POST-запроса submission наш запрос регистрируется на сервере, и создаётся процесс. Ответ на POST-запрос — token, необходимый для проверки статуса выполнения (Processing, Accepted, Time Limit Exceeded, Runtime Exceptions и др.).
- По возвращении успешность результатов можно проверить с помощью условий, а затем показать результаты в окне вывода.
const handleCompile = () => {
setProcessing(true);
const formData = {
language_id: language.id,
// encode source code in base64
source_code: btoa(code),
stdin: btoa(customInput),
};
const options = {
method: "POST",
url: process.env.REACT_APP_RAPID_API_URL,
params: { base64_encoded: "true", fields: "*" },
headers: {
"content-type": "application/json",
"Content-Type": "application/json",
"X-RapidAPI-Host": process.env.REACT_APP_RAPID_API_HOST,
"X-RapidAPI-Key": process.env.REACT_APP_RAPID_API_KEY,
},
data: formData,
};
axios
.request(options)
.then(function (response) {
console.log("res.data", response.data);
const token = response.data.token;
checkStatus(token);
})
.catch((err) => {
let error = err.response ? err.response.data : err;
setProcessing(false);
console.log(error);
});
};
Он принимает languageId, source_code и stdin. Обратите внимание на btoa перед source_code и stdin. Это нужно для кодирования строк в формате base64, потому что у нас в параметрах запроса к API есть base64_encoded: true.
Если получен успешный ответ и есть token, вызываем метод checkStatus() для опроса маршрута /submissions/${token}:
const checkStatus = async (token) => {
const options = {
method: "GET",
url: process.env.REACT_APP_RAPID_API_URL + "/" + token,
params: { base64_encoded: "true", fields: "*" },
headers: {
"X-RapidAPI-Host": process.env.REACT_APP_RAPID_API_HOST,
"X-RapidAPI-Key": process.env.REACT_APP_RAPID_API_KEY,
},
};
try {
let response = await axios.request(options);
let statusId = response.data.status?.id;
// Processed - we have a result
if (statusId === 1 || statusId === 2) {
// still processing
setTimeout(() => {
checkStatus(token)
}, 2000)
return
} else {
setProcessing(false)
setOutputDetails(response.data)
showSuccessToast(`Compiled Successfully!`)
console.log('response.data', response.data)
return
}
} catch (err) {
console.log("err", err);
setProcessing(false);
showErrorToast();
}
};
Чтобы получить результаты отправленного ранее кода, нужно опросить submissions с помощью token из ответа. Для этого выполняем GET-запрос к конечной точке. После получения ответа проверяем statusId === 1 || statusId === 2. Но что это значит? У нас 14 статусов, связанных с любой отправляемой в API частью кода:
export const statuses = [
{
id: 1,
description: "In Queue",
},
{
id: 2,
description: "Processing",
},
{
id: 3,
description: "Accepted",
},
{
id: 4,
description: "Wrong Answer",
},
{
id: 5,
description: "Time Limit Exceeded",
},
{
id: 6,
description: "Compilation Error",
},
{
id: 7,
description: "Runtime Error (SIGSEGV)",
},
{
id: 8,
description: "Runtime Error (SIGXFSZ)",
},
{
id: 9,
description: "Runtime Error (SIGFPE)",
},
{
id: 10,
description: "Runtime Error (SIGABRT)",
},
{
id: 11,
description: "Runtime Error (NZEC)",
},
{
id: 12,
description: "Runtime Error (Other)",
},
{
id: 13,
description: "Internal Error",
},
{
id: 14,
description: "Exec Format Error",
},
];
Если statusId === 1 или statusId === 2, код обрабатывается, и нужно снова вызвать API и проверить, получен ли результат. Из-за этого в if прописан setTimeout(), где снова вызывается функция checkStatus(), а внутри неё снова вызывается API и проверяется статус.
Если статус не 2 или 3, выполнение кода завершено и есть результат — успешно скомпилированный код или код с превышением предела времени компиляции. А может, код с исключением времени выполнения; statusId представляет все ситуации, которые тоже можно воспроизвести.
Например, в while(true) выдаётся ошибка превышения предела времени:
Или, если допущена ошибка синтаксиса, вернётся ошибка компиляции:
Так или иначе, есть результат, который сохраняется в состоянии outputDetails, чтобы было что отображать в окне вывода, в правой части экрана.
Компонент окна вывода
const OutputWindow = ({ outputDetails }) => {
const getOutput = () => {
let statusId = outputDetails?.status?.id;
if (statusId === 6) {
// compilation error
return (
<pre className="px-2 py-1 font-normal text-xs text-red-500">
{atob(outputDetails?.compile_output)}
</pre>
);
} else if (statusId === 3) {
return (
<pre className="px-2 py-1 font-normal text-xs text-green-500">
{atob(outputDetails.stdout) !== null
? `${atob(outputDetails.stdout)}`
: null}
</pre>
);
} else if (statusId === 5) {
return (
<pre className="px-2 py-1 font-normal text-xs text-red-500">
{`Time Limit Exceeded`}
</pre>
);
} else {
return (
<pre className="px-2 py-1 font-normal text-xs text-red-500">
{atob(outputDetails?.stderr)}
</pre>
);
}
};
return (
<>
<h1 className="font-bold text-xl bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-700 mb-2">
Output
</h1>
<div className="w-full h-56 bg-[#1e293b] rounded-md text-white font-normal text-sm overflow-y-auto">
{outputDetails ? <>{getOutput()}</> : null}
</div>
</>
);
};
export default OutputWindow;
Это простой компонент для отображения успеха или неуспеха компиляции. В методе getOutput() определяются вывод и цвет текста.
- Если statusId равен 3, имеем успешный сценарий со статусом Accepted. От API возвращается stdout — Standard Output («Стандартный вывод»). Он нужен для отображения данных, возвращаемых из отправленного в API кода.
- Если statusId равен 5, имеем ошибку превышения предела времени. Просто показываем, что в коде есть условие бесконечного цикла или превышено стандартное время выполнения кода 5 секунд.
- Если statusId равен 6, имеем ошибку компиляции. В этом случае API возвращает compile_output с возможностью отображения ошибки.
- При любом другом статусе получаем стандартный объект stderr для отображения ошибок.
- Обратите внимание: используется метод atob(), потому что выходные данные — это строка в base64. Тот же метод нужен, чтобы декодировать её.
Компонент вывода подробностей
Компонент OutputDetails — это простой модуль сопоставления для вывода данных, связанных с изначально скомпилированным фрагментом кода. Данные уже заданы в переменной состояния outputDetails:
import React from "react";
const OutputDetails = ({ outputDetails }) => {
return (
<div className="metrics-container mt-4 flex flex-col space-y-3">
<p className="text-sm">
Status:{" "}
<span className="font-semibold px-2 py-1 rounded-md bg-gray-100">
{outputDetails?.status?.description}
</span>
</p>
<p className="text-sm">
Memory:{" "}
<span className="font-semibold px-2 py-1 rounded-md bg-gray-100">
{outputDetails?.memory}
</span>
</p>
<p className="text-sm">
Time:{" "}
<span className="font-semibold px-2 py-1 rounded-md bg-gray-100">
{outputDetails?.time}
</span>
</p>
</div>
);
};
export default OutputDetails;
time, memory и status.description читаются из ответа от API, а затем сохраняются в outputDetails и отображаются.
События клавиатуры
И последнее — ctrl+enter для компиляции. Чтобы прослушивать в веб-приложении события клавиатуры, создаётся пользовательский хук, крутой и намного чище:// useKeyPress.js
import React, { useState } from "react";
const useKeyPress = function (targetKey) {
const [keyPressed, setKeyPressed] = useState(false);
function downHandler({ key }) {
if (key === targetKey) {
setKeyPressed(true);
}
}
const upHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
React.useEffect(() => {
document.addEventListener("keydown", downHandler);
document.addEventListener("keyup", upHandler);
return () => {
document.removeEventListener("keydown", downHandler);
document.removeEventListener("keyup", upHandler);
};
});
return keyPressed;
};
export default useKeyPress;
// Landing.js
...
...
...
const Landing = () => {
...
...
const enterPress = useKeyPress("Enter");
const ctrlPress = useKeyPress("Control");
...
...
}
Здесь для прослушивания целевой клавиши нужны нативные прослушиватели событий JavaScript. События keydown и keyup прослушиваются с помощью хука. Хук инициализируется целевой клавишей Enter и Control. Проверяется targetKey === key и, соответственно, задаётся keyPressed, поэтому можно использовать возвращаемое логическое значение keyPressed — true или false.
Теперь можно прослушать эти события в хуке useEffect и убедиться, что обе клавиши нажаты одновременно:
useEffect(() => {
if (enterPress && ctrlPress) {
console.log("enterPress", enterPress);
console.log("ctrlPress", ctrlPress);
handleCompile();
}
}, [ctrlPress, enterPress]);
Метод handleCompile() вызывается, когда пользователь нажимает Ctrl и Enter последовательно или одновременно.
Что нужно учитывать
Работать было интересно, но базовый план Judge0 о.ограничен, например, сотней запросов в день. Чтобы обойти ограничения, можно поднять собственный сервер/дроплет (на Digital Ocean) и разместить проект с открытым исходным кодом на своём хостинге, документация для этого отличная.Заключение
В итоге у нас появился:- редактор кода, способный компилировать более 40 языков;
- переключатель тем;
- API — интерактивные и размещаемые на RapidAPI;
- прослушивание событий клавиатуры через кастомные хуки React;
- и много всего интересного!
- Модуль авторизации и регистрации — для сохранения кода в собственном дашборде.
- Способ совместного использования кода через Интернет.
- Страница и настройки профиля.
- Работа вдвоём над одним фрагментом кода с использованием программирования сокетов и операционных преобразований.
- Закладки для фрагментов кода.
- Пользовательский дашборд с сохранением, как CodePen.
Как создать редактор кода для 40+ языков с помощью React
Подробностями разработки онлайн-платформы выполнения и компиляции кода более чем на 40 языках делимся к старту курса по Frontend-разработке . Автор этого материала — основатель TailwindMasterKit....
habr.com