This commit is contained in:
2025-10-29 20:36:09 -07:00
commit d90a9b5826
33 changed files with 3239 additions and 0 deletions

30
webui/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,30 @@
import "@/styles/globals.css";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Rhai Playground",
description: "An interactive Rhai scripting language playground",
};
export const viewport = {
width: "device-width",
initialScale: 1,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<link
rel="stylesheet"
href="https://cdn.materialdesignicons.com/5.3.45/css/materialdesignicons.min.css"
/>
</head>
<body>{children}</body>
</html>
);
}

78
webui/src/app/page.tsx Normal file
View File

@@ -0,0 +1,78 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
const Playground = dynamic(() => import("@/components/Playground"), {
ssr: false,
loading: () => (
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
right: 0,
padding: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div>
Loading{" "}
<a
href="https://github.com/rhaiscript/playground"
target="_blank"
>
Rhai Playground
</a>
...
</div>
</div>
),
});
export default function Home() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return (
<div
id="loading"
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
right: 0,
padding: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div>
Loading{" "}
<a
href="https://github.com/rhaiscript/playground"
target="_blank"
>
Rhai Playground
</a>
...
</div>
</div>
);
}
return (
<main>
<Playground />
</main>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useEffect, useRef, forwardRef, useImperativeHandle, useState } from "react";
import styles from "@/styles/Editor.module.css";
// Dynamic import for CodeMirror to avoid SSR issues
let CodeMirror: any = null;
if (typeof window !== "undefined") {
import("codemirror")
.then(async (cm) => {
CodeMirror = cm.default;
await import("codemirror/mode/javascript/javascript");
await import("codemirror/addon/edit/matchbrackets");
await import("codemirror/addon/edit/closebrackets");
await import("codemirror/addon/selection/active-line");
await import("codemirror/lib/codemirror.css");
await import("codemirror/theme/monokai.css");
});
}
interface EditorProps {
initialValue?: string;
onChange?: (editor: any, changes: any) => void;
onRequestRun?: () => void;
onReady?: (editor: any) => void;
}
export const Editor = forwardRef<any, EditorProps>(function Editor(
{ initialValue = "", onChange, onRequestRun, onReady },
ref,
) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<any>(null);
const [content, setContent] = useState(initialValue);
useImperativeHandle(ref, () => editorRef.current);
// Initialize editor only once
useEffect(() => {
if (!CodeMirror || !textareaRef.current || editorRef.current) return;
const editor = CodeMirror.fromTextArea(textareaRef.current, {
lineNumbers: true,
mode: "javascript", // Placeholder mode, will be 'rhai' when WASM is integrated
theme: "monokai",
matchBrackets: true,
autoCloseBrackets: true,
styleActiveLine: true,
extraKeys: {
"Ctrl-Enter": () => {
onRequestRun?.();
},
},
});
editor.setValue(content);
editor.on("change", (instance: any, changes: any) => {
const newContent = instance.getValue();
setContent(newContent);
onChange?.(instance, changes);
});
editorRef.current = editor;
onReady?.(editor);
return () => {
if (editorRef.current) {
editorRef.current.toTextArea();
editorRef.current = null;
}
};
}, []); // Empty dependency array - only initialize once
// Update keyboard shortcut when onRequestRun changes
useEffect(() => {
if (editorRef.current && onRequestRun) {
editorRef.current.setOption("extraKeys", {
"Ctrl-Enter": () => {
onRequestRun();
},
});
}
}, [onRequestRun]);
return (
<div className={styles.editorContainer}>
<textarea
ref={textareaRef}
defaultValue={initialValue}
placeholder="Enter your Rhai code here..."
/>
</div>
);
});

View File

@@ -0,0 +1,220 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/Button";
import { Dropdown } from "@/components/ui/Dropdown";
import { Editor } from "@/components/Editor";
import { Terminal, TerminalRef } from "@/components/Terminal";
import { runScript, stopScript } from "@/lib/runner";
import styles from "@/styles/Playground.module.css";
const initialCode = `
fn random_action(board) {
let symb = rand_symb();
let pos = rand_int(0, 10);
let action = Action(symb, pos);
while !board.can_play(action) {
let symb = rand_symb();
let pos = rand_int(0, 10);
action = Action(symb, pos);
}
return action
}
fn step_min(board) {
random_action(board)
}
fn step_max(board) {
random_action(board)
}
`;
const exampleScriptList = [
{ value: "./hello.rhai", text: "hello.rhai" },
{ value: "./fibonacci.rhai", text: "fibonacci.rhai" },
{ value: "./arrays.rhai", text: "arrays.rhai" },
];
export default function Playground() {
const [isScriptRunning, setIsScriptRunning] = useState(false);
const [isEditorReady, setIsEditorReady] = useState(false);
const editorRef = useRef<any>(null);
const resultRef = useRef<HTMLTextAreaElement>(null);
const terminalRef = useRef<TerminalRef>(null);
const runDisabled = isScriptRunning || !isEditorReady;
const stopDisabled = !isScriptRunning;
const requestRun = useCallback(async () => {
if (runDisabled || !editorRef.current) return;
setIsScriptRunning(true);
try {
terminalRef.current?.clear();
await runScript(
editorRef.current.getValue(),
(line: string) => {
// Only script prints go to output text area
if (resultRef.current) {
let v = resultRef.current.value + line + "\n";
if (v.length > 10000) {
v = v.substring(v.length - 10000);
}
resultRef.current.value = v;
resultRef.current.scrollTop =
resultRef.current.scrollHeight -
resultRef.current.clientHeight;
}
},
(line: string) => {
// Game state and debug info go to terminal
terminalRef.current?.write(line + '\r\n');
}
);
} catch (ex) {
const errorMsg = `\nEXCEPTION: "${ex}"\n`;
if (resultRef.current) {
resultRef.current.value += errorMsg;
}
terminalRef.current?.write('\r\n\x1B[1;31mEXCEPTION:\x1B[0m ' + String(ex) + '\r\n');
}
setIsScriptRunning(false);
}, [runDisabled]);
const stopScriptHandler = useCallback(() => {
stopScript();
}, []);
return (
<div className={styles.playgroundRoot}>
<header className={styles.header}>
<div className={styles.headerField}>
<div className={styles.buttonGroup}>
<Button
variant="success"
iconLeft="play"
onClick={() => {
if (resultRef.current) {
resultRef.current.value = "";
}
requestRun();
}}
loading={isScriptRunning}
disabled={runDisabled}
>
Run
</Button>
<Button
variant="danger"
iconLeft="stop"
onClick={stopScriptHandler}
disabled={stopDisabled}
>
Stop
</Button>
</div>
<div className={styles.buttonGroup}>
<Dropdown
trigger="Example Scripts"
disabled={isScriptRunning}
items={exampleScriptList.map((item) => ({
text: item.text,
onClick: () => {},
}))}
/>
<Dropdown
triggerIcon="help-circle"
align="right"
customContent={
<div className={styles.helpPanel}>
<h1>What is Rhai?</h1>
<p>
<a
href="https://rhai.rs"
target="_blank"
rel="noopener noreferrer"
>
Rhai
</a>{" "}
is an embedded scripting language and
evaluation engine for Rust that gives a
safe and easy way to add scripting to
any application.
</p>
<h1>Hotkeys</h1>
<p>
You can run the script by pressing{" "}
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> when
focused in the editor.
</p>
<div className={styles.footer}>
<span>
<a
href="https://github.com/rhaiscript/playground"
target="_blank"
rel="noopener noreferrer"
>
Rhai Playground
</a>{" "}
version: 0.1.0
</span>
<br />
<span>
compiled with Rhai (placeholder)
</span>
</div>
</div>
}
/>
</div>
</div>
</header>
<div className={styles.mainContent}>
<div className={styles.leftPanel}>
<Editor
ref={editorRef}
initialValue={initialCode}
onChange={() => {}}
onRequestRun={() => {
if (resultRef.current) {
resultRef.current.value = "";
}
requestRun();
}}
onReady={() => setIsEditorReady(true)}
/>
</div>
<div className={styles.rightPanel}>
<div className={styles.terminalPanel}>
<div className={styles.panelHeader}>Terminal</div>
<div className={styles.terminalContainer}>
<Terminal ref={terminalRef} />
</div>
</div>
<div className={styles.outputPanel}>
<div className={styles.panelHeader}>Output</div>
<textarea
ref={resultRef}
className={styles.result}
readOnly
autoComplete="off"
placeholder="Script output will appear here..."
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect, useRef, useImperativeHandle, forwardRef } from "react";
import '@xterm/xterm/css/xterm.css';
export interface TerminalRef {
write: (data: string) => void;
clear: () => void;
}
export const Terminal = forwardRef<TerminalRef, {}>(function Terminal(_props, ref) {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<any>(null);
useImperativeHandle(ref, () => ({
write: (data: string) => {
if (xtermRef.current) {
xtermRef.current.write(data);
}
},
clear: () => {
if (xtermRef.current) {
xtermRef.current.clear();
}
}
}));
useEffect(() => {
let mounted = true;
const initTerminal = async () => {
if (!terminalRef.current) return;
try {
const { Terminal } = await import('@xterm/xterm');
if (!mounted) return;
const term = new Terminal({
//"fontFamily": "Fantasque",
"rows": 24,
"fontSize": 16,
"tabStopWidth": 8,
"cursorBlink": true,
"theme": {
"background": "#1D1F21",
"foreground": "#F8F8F8",
"cursor": "#F8F8F2",
"black": "#282828",
"blue": "#0087AF",
"brightBlack": "#555555",
"brightBlue": "#87DFFF",
"brightCyan": "#28D1E7",
"brightGreen": "#A8FF60",
"brightMagenta": "#985EFF",
"brightRed": "#FFAA00",
"brightWhite": "#D0D0D0",
"brightYellow": "#F1FF52",
"cyan": "#87DFEB",
"green": "#B4EC85",
"magenta": "#BD99FF",
"red": "#FF6600",
"white": "#F8F8F8",
"yellow": "#FFFFB6"
}
});
term.open(terminalRef.current);
term.write('Terminal ready...\r\n');
xtermRef.current = term;
} catch (error) {
console.error('Failed to initialize terminal:', error);
}
};
initTerminal();
return () => {
mounted = false;
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
};
}, []);
return <div ref={terminalRef} style={{ height: "100%", width: "100%" }} />;
});

View File

@@ -0,0 +1,56 @@
"use client";
import { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import styles from "@/styles/Button.module.css";
interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
variant?:
| "primary"
| "success"
| "danger"
| "warning"
| "info"
| "light"
| "dark";
iconLeft?: string;
iconRight?: string;
loading?: boolean;
children: ReactNode;
tooltip?: string;
type?: "submit" | "reset" | "button";
}
export function Button({
variant = "primary",
iconLeft,
iconRight,
loading = false,
children,
className,
disabled,
tooltip,
type = "button",
...props
}: ButtonProps) {
return (
<button
type={type}
className={clsx(
styles.button,
styles[`is-${variant}`],
loading && styles.isLoading,
className,
)}
disabled={disabled || loading}
title={tooltip}
{...props}
>
{iconLeft && !loading && <i className={`mdi mdi-${iconLeft}`} />}
{loading && <i className="mdi mdi-loading mdi-spin" />}
<span>{children}</span>
{iconRight && !loading && <i className={`mdi mdi-${iconRight}`} />}
</button>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useState, useRef, useEffect, ReactNode } from "react";
import clsx from "clsx";
import styles from "@/styles/Dropdown.module.css";
interface DropdownItem {
text: string;
onClick: () => void;
}
interface DropdownProps {
trigger?: string;
triggerIcon?: string;
disabled?: boolean;
items?: DropdownItem[];
customContent?: ReactNode;
align?: "left" | "right";
}
export function Dropdown({
trigger,
triggerIcon,
disabled = false,
items = [],
customContent,
align = "left",
}: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
return (
<div
className={clsx(styles.dropdown, isOpen && styles.isActive)}
ref={dropdownRef}
>
<button
className={styles.dropdownTrigger}
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
>
{triggerIcon && <i className={`mdi mdi-${triggerIcon}`} />}
{trigger && <span>{trigger}</span>}
{!triggerIcon && !trigger && (
<i className="mdi mdi-dots-horizontal" />
)}
<i className="mdi mdi-menu-down" />
</button>
{isOpen && (
<div
className={clsx(
styles.dropdownMenu,
align === "right" && styles.alignRight,
)}
>
<div className={styles.dropdownContent}>
{customContent ? (
<div className={styles.dropdownItem}>
{customContent}
</div>
) : (
items.map((item, index) => (
<a
key={index}
href="#"
className={styles.dropdownItem}
onClick={(e) => {
e.preventDefault();
item.onClick();
setIsOpen(false);
}}
>
{item.text}
</a>
))
)}
</div>
</div>
)}
</div>
);
}

53
webui/src/lib/runner.ts Normal file
View File

@@ -0,0 +1,53 @@
"use client";
let worker: Worker | null = null;
export async function runScript(
script: string,
appendOutput: (line: string) => void,
appendTerminal?: (line: string) => void
): Promise<void> {
return new Promise((resolve, reject) => {
if (worker) {
worker.terminate();
}
worker = new Worker(new URL('./script-runner.worker.ts', import.meta.url));
worker.onmessage = (event) => {
const { type, line, error } = event.data;
if (type === 'output') {
appendOutput(line);
} else if (type === 'terminal') {
appendTerminal?.(line);
} else if (type === 'complete') {
worker?.terminate();
worker = null;
resolve();
} else if (type === 'error') {
worker?.terminate();
worker = null;
reject(new Error(error));
} else if (type === 'stopped') {
worker?.terminate();
worker = null;
resolve();
}
};
worker.onerror = (error) => {
worker?.terminate();
worker = null;
reject(error);
};
worker.postMessage({ type: 'run', script });
});
}
export function stopScript(): void {
if (worker) {
worker.postMessage({ type: 'stop' });
}
}

View File

@@ -0,0 +1,73 @@
import init, { GameState } from "./wasm/script_runner";
let wasmReady = false;
let wasmInitPromise: Promise<void> | null = null;
let currentGame: GameState | null = null;
async function initWasm(): Promise<void> {
if (wasmReady) return;
if (wasmInitPromise) {
return wasmInitPromise;
}
wasmInitPromise = (async () => {
await init();
wasmReady = true;
})();
return wasmInitPromise;
}
self.onmessage = async (event) => {
const { type, script } = event.data;
if (type === "run") {
try {
await initWasm();
const appendOutput = (line: string) => {
self.postMessage({ type: "output", line });
};
const appendDebug = (line: string) => {
self.postMessage({ type: "output", line }); // debug also goes to output
};
const appendTerminal = (line: string) => {
self.postMessage({ type: "terminal", line });
};
currentGame = new GameState(
script,
"max",
appendOutput,
appendDebug,
script,
"min",
appendOutput,
appendDebug,
appendTerminal
);
while (currentGame && !currentGame.is_done()) {
const res = currentGame.step();
if (res === undefined) {
self.postMessage({ type: "complete" });
return;
}
}
if (currentGame) {
self.postMessage({ type: "complete" });
}
} catch (error) {
self.postMessage({ type: "error", error: String(error) });
}
} else if (type === "stop") {
currentGame = null;
self.postMessage({ type: "stopped" });
}
};

View File

@@ -0,0 +1,118 @@
.button {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: inherit;
font-size: 14px;
font-weight: 500;
padding: 8px 16px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
background: #2d2d2d;
color: #e0e0e0;
border-color: #555;
}
.button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
background: #3a3a3a;
border-color: #666;
}
.button:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
.isLoading {
cursor: wait;
}
/* Button types */
.is-primary {
background: #007bff;
color: white;
border-color: #007bff;
}
.is-primary:hover:not(:disabled) {
background: #0056b3;
border-color: #0056b3;
}
.is-success {
background: #28a745;
color: white;
border-color: #28a745;
}
.is-success:hover:not(:disabled) {
background: #1e7e34;
border-color: #1e7e34;
}
.is-danger {
background: #dc3545;
color: white;
border-color: #dc3545;
}
.is-danger:hover:not(:disabled) {
background: #c82333;
border-color: #c82333;
}
.is-warning {
background: #ffc107;
color: #212529;
border-color: #ffc107;
}
.is-warning:hover:not(:disabled) {
background: #e0a800;
border-color: #e0a800;
}
.is-info {
background: #17a2b8;
color: white;
border-color: #17a2b8;
}
.is-info:hover:not(:disabled) {
background: #138496;
border-color: #138496;
}
.is-light {
background: #f8f9fa;
color: #212529;
border-color: #f8f9fa;
}
.is-light:hover:not(:disabled) {
background: #e2e6ea;
border-color: #e2e6ea;
}
.is-dark {
background: #343a40;
color: white;
border-color: #343a40;
}
.is-dark:hover:not(:disabled) {
background: #23272b;
border-color: #23272b;
}

View File

@@ -0,0 +1,100 @@
.dropdown {
position: relative;
display: inline-block;
}
.dropdownTrigger {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: inherit;
font-size: 14px;
font-weight: 500;
padding: 8px 16px;
border: 1px solid #555;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
background: #2d2d2d;
color: #e0e0e0;
}
.dropdownTrigger:hover:not(:disabled) {
background: #3a3a3a;
border-color: #666;
}
.dropdownTrigger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.isActive .dropdownTrigger {
background: #3a3a3a;
border-color: #4fc3f7;
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.25);
}
.dropdownMenu {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
min-width: 160px;
max-width: 90vw;
margin-top: 4px;
background: #2d2d2d;
border: 1px solid #555;
border-radius: 4px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
animation: dropdownFadeIn 0.15s ease-out;
box-sizing: border-box;
}
.dropdownMenu.alignRight {
left: auto;
right: 0;
}
@keyframes dropdownFadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdownContent {
padding: 4px 0;
overflow: hidden;
}
.dropdownItem {
display: block;
width: 100%;
padding: 8px 16px;
clear: both;
font-weight: 400;
color: #e0e0e0;
text-align: inherit;
text-decoration: none;
white-space: normal;
background-color: transparent;
transition: background-color 0.15s ease-in-out;
cursor: pointer;
border: none;
box-sizing: border-box;
word-wrap: break-word;
overflow-wrap: break-word;
}
.dropdownItem:hover,
.dropdownItem:focus {
background-color: #3a3a3a;
color: #ffffff;
text-decoration: none;
}

View File

@@ -0,0 +1,17 @@
.editorContainer {
height: 100%;
position: relative;
}
.editorContainer textarea {
width: 100%;
height: 100%;
border: 1px solid #ccc;
font-family: "Consolas", "Monaco", "Courier New", monospace;
font-size: 14px;
line-height: 1.4;
padding: 8px;
box-sizing: border-box;
resize: none;
outline: none;
}

View File

@@ -0,0 +1,133 @@
.playgroundRoot {
height: 100vh;
max-height: 100vh;
display: flex;
flex-direction: column;
background: #1a1a1a;
color: #e0e0e0;
}
.header {
padding: 0.75rem;
border-bottom: 1px solid #333;
background: #252526;
}
.headerField {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.buttonGroup {
display: flex;
gap: 8px;
align-items: center;
}
.mainContent {
flex: 1;
display: flex;
height: 100%;
}
.leftPanel {
width: 50%;
border-right: 1px solid #333;
}
.rightPanel {
width: 50%;
display: flex;
flex-direction: column;
}
.terminalPanel {
flex: 1;
display: flex;
flex-direction: column;
border-bottom: 1px solid #333;
}
.outputPanel {
flex: 1;
display: flex;
flex-direction: column;
}
.panelHeader {
background: #2d2d30;
border-bottom: 1px solid #333;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: #cccccc;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.terminalContainer {
flex: 1;
overflow: hidden;
}
.result {
border: 0;
margin: 0;
padding: 8px;
resize: none;
font-family: "Consolas", "Monaco", "Courier New", monospace;
flex: 1;
outline: none;
font-size: 13px;
line-height: 1.4;
background: #1e1e1e;
color: #d4d4d4;
width: 100%;
box-sizing: border-box;
}
.configPanel {
padding: 16px;
min-width: 250px;
}
.helpPanel {
padding: 16px;
width: 300px;
max-width: 90vw;
box-sizing: border-box;
}
.helpPanel h1 {
font-size: 18px;
margin: 0 0 8px 0;
color: #e0e0e0;
}
.helpPanel p {
margin: 0 0 16px 0;
line-height: 1.5;
color: #cccccc;
word-wrap: break-word;
overflow-wrap: break-word;
}
.helpPanel a {
color: #4fc3f7;
text-decoration: none;
}
.helpPanel a:hover {
text-decoration: underline;
}
.footer {
font-size: 12px;
color: #999;
border-top: 1px solid #444;
padding-top: 12px;
margin-top: 12px;
}

View File

@@ -0,0 +1,119 @@
/* Global styles based on the original site */
html,
body {
height: 100%;
margin: 0;
overflow: auto !important;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
background: #1a1a1a;
color: #e0e0e0;
}
#loading {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
}
/* CodeMirror base styles */
.CodeMirror {
border: 1px solid #444;
height: 100% !important;
box-sizing: border-box;
font-size: 0.95em;
line-height: initial;
font-family: "Consolas", "Monaco", "Courier New", monospace;
background: #1e1e1e;
color: #d4d4d4;
}
.CodeMirror .rhai-error {
text-decoration: underline wavy red;
}
.CodeMirror .cm-matchhighlight {
background-color: rgba(0, 0, 0, 0.1);
}
.CodeMirror .CodeMirror-selection-highlight-scrollbar {
background-color: rgba(0, 0, 0, 0.1);
}
/* Basic button styles */
button {
font-family: inherit;
font-size: 14px;
padding: 8px 12px;
border: 1px solid #555;
background: #2d2d2d;
color: #e0e0e0;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
button:hover:not(:disabled) {
background: #3a3a3a;
border-color: #666;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Input styles */
input,
select,
textarea {
font-family: inherit;
font-size: 14px;
padding: 8px;
border: 1px solid #555;
border-radius: 4px;
box-sizing: border-box;
background: #2d2d2d;
color: #e0e0e0;
}
select {
background: #2d2d2d;
cursor: pointer;
}
textarea {
resize: vertical;
font-family: "Consolas", "Monaco", "Courier New", monospace;
}
/* Form elements */
label {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
display: block;
color: #e0e0e0;
}
/* Kbd styling */
kbd {
background: #333;
border: 1px solid #555;
border-radius: 3px;
color: #e0e0e0;
display: inline-block;
font-size: 12px;
line-height: 1.4;
margin: 0 2px;
padding: 1px 5px;
white-space: nowrap;
}

10
webui/src/types/codemirror.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
// Type declarations for CodeMirror modules
declare module "codemirror/mode/javascript/javascript";
declare module "codemirror/addon/edit/matchbrackets";
declare module "codemirror/addon/edit/closebrackets";
declare module "codemirror/addon/selection/active-line";
declare module "codemirror/addon/fold/foldcode";
declare module "codemirror/addon/fold/foldgutter";
declare module "codemirror/addon/fold/brace-fold";
declare module "codemirror/lib/codemirror.css";
declare module "codemirror/addon/fold/foldgutter.css";