feat: integrate Monaco Editor with tabs, NWScript syntax, and session persistence

This commit is contained in:
plenarius
2026-04-20 19:06:00 -04:00
parent 42d7f4b041
commit eaca2d8a6c
8 changed files with 617 additions and 0 deletions
@@ -0,0 +1,278 @@
import { useRef, useCallback } from "react";
import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react";
import type { editor } from "monaco-editor";
interface MonacoEditorProps {
filePath: string;
content: string;
language?: string;
onChange?: (value: string) => void;
}
let nwscriptRegistered = false;
function registerNWScript(monaco: Parameters<OnMount>[1]) {
if (nwscriptRegistered) return;
nwscriptRegistered = true;
monaco.languages.register({ id: "nwscript", extensions: [".nss"] });
monaco.languages.setMonarchTokensProvider("nwscript", {
keywords: [
"void",
"int",
"float",
"string",
"object",
"effect",
"itemproperty",
"location",
"vector",
"action",
"talent",
"event",
"struct",
"if",
"else",
"while",
"for",
"do",
"switch",
"case",
"default",
"break",
"continue",
"return",
"const",
],
constants: ["TRUE", "FALSE", "OBJECT_SELF", "OBJECT_INVALID"],
typeKeywords: [
"void",
"int",
"float",
"string",
"object",
"effect",
"itemproperty",
"location",
"vector",
"action",
"talent",
"event",
"struct",
],
operators: [
"=",
">",
"<",
"!",
"~",
"?",
":",
"==",
"<=",
">=",
"!=",
"&&",
"||",
"++",
"--",
"+",
"-",
"*",
"/",
"&",
"|",
"^",
"%",
"<<",
">>",
"+=",
"-=",
"*=",
"/=",
"&=",
"|=",
"^=",
"%=",
],
symbols: /[=><!~?:&|+\-*/^%]+/,
tokenizer: {
root: [
[/#include\b/, "keyword.preprocessor"],
[/#define\b/, "keyword.preprocessor"],
[
/[a-zA-Z_]\w*/,
{
cases: {
"@constants": "constant",
"@typeKeywords": "type",
"@keywords": "keyword",
"@default": "identifier",
},
},
],
{ include: "@whitespace" },
[/[{}()[\]]/, "@brackets"],
[/[;,.]/, "delimiter"],
[
/@symbols/,
{
cases: {
"@operators": "operator",
"@default": "",
},
},
],
[/\d*\.\d+([eE][-+]?\d+)?[fF]?/, "number.float"],
[/0[xX][0-9a-fA-F]+/, "number.hex"],
[/\d+/, "number"],
[/"([^"\\]|\\.)*$/, "string.invalid"],
[/"/, { token: "string.quote", next: "@string" }],
],
string: [
[/[^\\"]+/, "string"],
[/\\./, "string.escape"],
[/"/, { token: "string.quote", next: "@pop" }],
],
whitespace: [
[/[ \t\r\n]+/, "white"],
[/\/\*/, "comment", "@comment"],
[/\/\/.*$/, "comment"],
],
comment: [
[/[^/*]+/, "comment"],
[/\*\//, "comment", "@pop"],
[/[/*]/, "comment"],
],
},
});
}
function defineForgeTheme(monaco: Parameters<OnMount>[1]) {
const style = getComputedStyle(document.documentElement);
const bg = style.getPropertyValue("--forge-bg").trim() || "#121212";
const surface = style.getPropertyValue("--forge-surface").trim() || "#1e1e2e";
const accent = style.getPropertyValue("--forge-accent").trim() || "#946200";
const text = style.getPropertyValue("--forge-text").trim() || "#f2f2f2";
const textSecondary =
style.getPropertyValue("--forge-text-secondary").trim() || "#888888";
const border = style.getPropertyValue("--forge-border").trim() || "#2e2e3e";
monaco.editor.defineTheme("forge-dark", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword", foreground: "C586C0" },
{ token: "keyword.preprocessor", foreground: "569CD6" },
{ token: "type", foreground: "4EC9B0" },
{ token: "constant", foreground: "4FC1FF" },
{ token: "string", foreground: "CE9178" },
{ token: "comment", foreground: "6A9955" },
{ token: "number", foreground: "B5CEA8" },
{ token: "operator", foreground: "D4D4D4" },
],
colors: {
"editor.background": bg,
"editor.foreground": text,
"editorCursor.foreground": accent,
"editor.lineHighlightBackground": surface,
"editorLineNumber.foreground": textSecondary,
"editorLineNumber.activeForeground": text,
"editor.selectionBackground": "#264f7840",
"editorWidget.background": surface,
"editorWidget.border": border,
"editorSuggestWidget.background": surface,
"editorSuggestWidget.border": border,
},
});
}
function languageFromPath(filePath: string): string {
const ext = filePath.split(".").pop()?.toLowerCase();
const map: Record<string, string> = {
nss: "nwscript",
json: "json",
sql: "sql",
css: "css",
html: "html",
xml: "xml",
js: "javascript",
ts: "typescript",
md: "markdown",
txt: "plaintext",
"2da": "plaintext",
};
return map[ext ?? ""] ?? "plaintext";
}
export function MonacoEditor({
filePath,
content,
language,
onChange,
}: MonacoEditorProps) {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const handleMount: OnMount = useCallback(
(editorInstance, monaco) => {
editorRef.current = editorInstance;
registerNWScript(monaco);
defineForgeTheme(monaco);
monaco.editor.setTheme("forge-dark");
const model = editorInstance.getModel();
if (model) {
const lang = language ?? languageFromPath(filePath);
monaco.editor.setModelLanguage(model, lang);
}
},
[filePath, language],
);
const handleChange = useCallback(
(value: string | undefined) => {
if (value !== undefined) {
onChange?.(value);
}
},
[onChange],
);
return (
<ReactMonacoEditor
value={content}
language={language ?? languageFromPath(filePath)}
theme="vs-dark"
onChange={handleChange}
onMount={handleMount}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 4,
insertSpaces: true,
wordWrap: "off",
renderWhitespace: "selection",
bracketPairColorization: { enabled: true },
padding: { top: 8 },
}}
/>
);
}