feat: integrate Monaco Editor with tabs, NWScript syntax, and session persistence
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user