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
+2
View File
@@ -1,11 +1,13 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Dashboard } from "./pages/Dashboard";
import { Editor } from "./pages/Editor";
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/editor" element={<Editor />} />
</Routes>
</BrowserRouter>
);
@@ -0,0 +1,74 @@
interface Tab {
path: string;
dirty: boolean;
}
interface EditorTabsProps {
tabs: Tab[];
activeTab: string | null;
onSelect: (path: string) => void;
onClose: (path: string) => void;
}
function filename(path: string): string {
return path.split("/").pop() ?? path;
}
export function EditorTabs({
tabs,
activeTab,
onSelect,
onClose,
}: EditorTabsProps) {
if (tabs.length === 0) return null;
return (
<div
className="flex overflow-x-auto"
style={{
backgroundColor: "var(--forge-surface)",
borderBottom: "1px solid var(--forge-border)",
}}
>
{tabs.map((tab) => {
const isActive = tab.path === activeTab;
return (
<button
key={tab.path}
title={tab.path}
onClick={() => onSelect(tab.path)}
className="group relative flex shrink-0 items-center gap-1.5 px-3 py-2 text-sm transition-colors"
style={{
color: isActive
? "var(--forge-text)"
: "var(--forge-text-secondary)",
borderBottom: isActive
? "2px solid var(--forge-accent)"
: "2px solid transparent",
}}
>
<span className="flex items-center gap-1">
{filename(tab.path)}
{tab.dirty && (
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: "var(--forge-accent)" }}
/>
)}
</span>
<span
onClick={(e) => {
e.stopPropagation();
onClose(tab.path);
}}
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-white/10 group-hover:opacity-100"
style={{ color: "var(--forge-text-secondary)" }}
>
×
</span>
</button>
);
})}
</div>
);
}
@@ -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 },
}}
/>
);
}
@@ -0,0 +1,128 @@
import { useState, useCallback, useEffect, useRef } from "react";
const STORAGE_KEY = "forge-editor-state";
interface PersistedState {
openTabs: string[];
activeTab: string | null;
}
function loadPersistedState(): PersistedState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
openTabs: Array.isArray(parsed.openTabs) ? parsed.openTabs : [],
activeTab:
typeof parsed.activeTab === "string" ? parsed.activeTab : null,
};
}
} catch {
// corrupted storage — start fresh
}
return { openTabs: [], activeTab: null };
}
function persistState(state: PersistedState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// quota exceeded — silently ignore
}
}
export function useEditorState() {
const [openTabs, setOpenTabs] = useState<string[]>(
() => loadPersistedState().openTabs,
);
const [activeTab, setActiveTab] = useState<string | null>(
() => loadPersistedState().activeTab,
);
const [dirtyFiles, setDirtyFiles] = useState<Set<string>>(new Set());
const fileContents = useRef<Map<string, string>>(new Map());
useEffect(() => {
persistState({ openTabs, activeTab });
}, [openTabs, activeTab]);
const openFile = useCallback(
(path: string, content?: string) => {
if (content !== undefined) {
fileContents.current.set(path, content);
}
setOpenTabs((prev) => {
if (prev.includes(path)) return prev;
return [...prev, path];
});
setActiveTab(path);
},
[],
);
const closeFile = useCallback(
(path: string) => {
setOpenTabs((prev) => {
const next = prev.filter((p) => p !== path);
setActiveTab((current) => {
if (current !== path) return current;
const idx = prev.indexOf(path);
return next[Math.min(idx, next.length - 1)] ?? null;
});
return next;
});
setDirtyFiles((prev) => {
const next = new Set(prev);
next.delete(path);
return next;
});
fileContents.current.delete(path);
},
[],
);
const selectTab = useCallback((path: string) => {
setActiveTab(path);
}, []);
const markDirty = useCallback((path: string) => {
setDirtyFiles((prev) => {
if (prev.has(path)) return prev;
return new Set(prev).add(path);
});
}, []);
const markClean = useCallback((path: string) => {
setDirtyFiles((prev) => {
if (!prev.has(path)) return prev;
const next = new Set(prev);
next.delete(path);
return next;
});
}, []);
const updateContent = useCallback(
(path: string, content: string) => {
fileContents.current.set(path, content);
markDirty(path);
},
[markDirty],
);
const getContent = useCallback((path: string): string | undefined => {
return fileContents.current.get(path);
}, []);
return {
openTabs,
activeTab,
dirtyFiles,
openFile,
closeFile,
selectTab,
markDirty,
markClean,
updateContent,
getContent,
};
}
+63
View File
@@ -0,0 +1,63 @@
import { useCallback, useMemo } from "react";
import { MonacoEditor } from "../components/editor/MonacoEditor";
import { EditorTabs } from "../components/editor/EditorTabs";
import { useEditorState } from "../hooks/useEditorState";
export function Editor() {
const {
openTabs,
activeTab,
dirtyFiles,
selectTab,
closeFile,
updateContent,
getContent,
} = useEditorState();
const tabs = useMemo(
() =>
openTabs.map((path: string) => ({
path,
dirty: dirtyFiles.has(path),
})),
[openTabs, dirtyFiles],
);
const activeContent = activeTab ? (getContent(activeTab) ?? "") : "";
const handleChange = useCallback(
(value: string) => {
if (activeTab) {
updateContent(activeTab, value);
}
},
[activeTab, updateContent],
);
return (
<div className="flex h-screen flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
<EditorTabs
tabs={tabs}
activeTab={activeTab}
onSelect={selectTab}
onClose={closeFile}
/>
<div className="flex-1 overflow-hidden">
{activeTab ? (
<MonacoEditor
key={activeTab}
filePath={activeTab}
content={activeContent}
onChange={handleChange}
/>
) : (
<div className="flex h-full items-center justify-center">
<p style={{ color: "var(--forge-text-secondary)" }} className="text-lg">
Open a file from the File Explorer to start editing
</p>
</div>
)}
</div>
</div>
);
}