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
+1
View File
@@ -3,3 +3,4 @@ dist/
.env .env
*.log *.log
.DS_Store .DS_Store
*.tsbuildinfo
+69
View File
@@ -860,6 +860,29 @@
"resolved": "packages/frontend", "resolved": "packages/frontend",
"link": true "link": true
}, },
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@protobufjs/aspromise": { "node_modules/@protobufjs/aspromise": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -1567,6 +1590,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -2271,6 +2301,15 @@
"node": ">= 8.0" "node": ">= 8.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2848,6 +2887,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2909,6 +2960,16 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3600,6 +3661,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -4719,6 +4786,8 @@
"name": "@layonara-forge/frontend", "name": "@layonara-forge/frontend",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0",
"monaco-editor": "^0.55.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0"
+2
View File
@@ -9,6 +9,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0",
"monaco-editor": "^0.55.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0"
+2
View File
@@ -1,11 +1,13 @@
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
import { Editor } from "./pages/Editor";
export function App() { export function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/editor" element={<Editor />} />
</Routes> </Routes>
</BrowserRouter> </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>
);
}