diff --git a/.gitignore b/.gitignore index 4274b51..5dc77dc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ .env *.log .DS_Store +*.tsbuildinfo diff --git a/package-lock.json b/package-lock.json index ca69a3a..19ae112 100644 --- a/package-lock.json +++ b/package-lock.json @@ -860,6 +860,29 @@ "resolved": "packages/frontend", "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1567,6 +1590,13 @@ "dev": true, "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": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2271,6 +2301,15 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2848,6 +2887,18 @@ "@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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2909,6 +2960,16 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3600,6 +3661,12 @@ "dev": true, "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4719,6 +4786,8 @@ "name": "@layonara-forge/frontend", "version": "0.0.1", "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" diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 034ee44..aff636b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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" diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 21c5b7f..fb4249d 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -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 ( } /> + } /> ); diff --git a/packages/frontend/src/components/editor/EditorTabs.tsx b/packages/frontend/src/components/editor/EditorTabs.tsx new file mode 100644 index 0000000..437ba4b --- /dev/null +++ b/packages/frontend/src/components/editor/EditorTabs.tsx @@ -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 ( +
+ {tabs.map((tab) => { + const isActive = tab.path === activeTab; + return ( + + ); + })} +
+ ); +} diff --git a/packages/frontend/src/components/editor/MonacoEditor.tsx b/packages/frontend/src/components/editor/MonacoEditor.tsx new file mode 100644 index 0000000..d00fe2b --- /dev/null +++ b/packages/frontend/src/components/editor/MonacoEditor.tsx @@ -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[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: /[=>[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 = { + 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(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 ( + + ); +} diff --git a/packages/frontend/src/hooks/useEditorState.ts b/packages/frontend/src/hooks/useEditorState.ts new file mode 100644 index 0000000..f179a83 --- /dev/null +++ b/packages/frontend/src/hooks/useEditorState.ts @@ -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( + () => loadPersistedState().openTabs, + ); + const [activeTab, setActiveTab] = useState( + () => loadPersistedState().activeTab, + ); + const [dirtyFiles, setDirtyFiles] = useState>(new Set()); + const fileContents = useRef>(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, + }; +} diff --git a/packages/frontend/src/pages/Editor.tsx b/packages/frontend/src/pages/Editor.tsx new file mode 100644 index 0000000..bb9ed65 --- /dev/null +++ b/packages/frontend/src/pages/Editor.tsx @@ -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 ( +
+ +
+ {activeTab ? ( + + ) : ( +
+

+ Open a file from the File Explorer to start editing +

+
+ )} +
+
+ ); +}