A
This commit is contained in:
95
webui/src/components/Editor.tsx
Normal file
95
webui/src/components/Editor.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
220
webui/src/components/Playground.tsx
Normal file
220
webui/src/components/Playground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
webui/src/components/Terminal.tsx
Normal file
89
webui/src/components/Terminal.tsx
Normal 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%" }} />;
|
||||
});
|
||||
56
webui/src/components/ui/Button.tsx
Normal file
56
webui/src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
webui/src/components/ui/Dropdown.tsx
Normal file
103
webui/src/components/ui/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user