Files
layonara-forge/packages/frontend/src/components/editor/MonacoEditor.tsx
T
plenarius f851d8b8f2 Layonara Forge — NWN Development IDE
Electron desktop application for Neverwinter Nights module development.
Clone, edit, build, and run a complete Layonara NWNX server with only
Docker required.

- React 19 + Vite frontend with Monaco editor and NWScript LSP
- Node.js + Express backend managing Docker sibling containers
- Electron shell with Docker availability check and auto-setup
- Builder image auto-builds on first use from bundled Dockerfile
- Cross-platform: Windows (.exe), macOS (.dmg), Linux (.AppImage)
- Gitea Actions CI for automated release builds
2026-04-21 12:14:38 -04:00

212 lines
7.3 KiB
TypeScript

import { useCallback, useMemo } from "react";
import { LogLevel } from "@codingame/monaco-vscode-api";
import {
MonacoEditorReactComp,
} from "@typefox/monaco-editor-react";
import type { MonacoVscodeApiConfig } from "monaco-languageclient/vscodeApiWrapper";
import type { EditorAppConfig, TextContents } from "monaco-languageclient/editorApp";
import type { LanguageClientConfig } from "monaco-languageclient/lcwrapper";
import { configureDefaultWorkerFactory } from "monaco-languageclient/workerFactory";
import "../../nwscript-extension/index.js";
function getVscodeApiConfig(): MonacoVscodeApiConfig {
return {
$type: "extended",
viewsConfig: {
$type: "EditorService",
},
userConfiguration: {
json: JSON.stringify({
"workbench.colorTheme": "Default Dark Modern",
"workbench.colorCustomizations": {
"editor.background": "#231e17",
"editor.foreground": "#ece8e3",
"editor.lineHighlightBackground": "#302a2040",
"editor.selectionBackground": "#3d3018",
"editor.selectionHighlightBackground": "#3d301860",
"editor.inactiveSelectionBackground": "#3d301850",
"editor.findMatchBackground": "#b07a0040",
"editor.findMatchHighlightBackground": "#b07a0025",
"editor.hoverHighlightBackground": "#3d301830",
"editorCursor.foreground": "#b07a00",
"editorWhitespace.foreground": "#4a403550",
"editorIndentGuide.background": "#4a403530",
"editorIndentGuide.activeBackground": "#4a403580",
"editorLineNumber.foreground": "#a69f9650",
"editorLineNumber.activeForeground": "#ece8e3",
"editorBracketMatch.background": "#3d301840",
"editorBracketMatch.border": "#b07a0080",
"editorOverviewRuler.border": "#4a4035",
"editorGutter.background": "#231e17",
"editorWidget.background": "#3b3328",
"editorWidget.foreground": "#ece8e3",
"editorWidget.border": "#4a4035",
"editorSuggestWidget.background": "#3b3328",
"editorSuggestWidget.border": "#4a4035",
"editorSuggestWidget.foreground": "#ece8e3",
"editorSuggestWidget.highlightForeground": "#b07a00",
"editorSuggestWidget.selectedBackground": "#3d3018",
"editorHoverWidget.background": "#3b3328",
"editorHoverWidget.border": "#4a4035",
"editorGroupHeader.tabsBackground": "#302a20",
"editorGroupHeader.tabsBorder": "#4a4035",
"editorGroup.border": "#4a4035",
"tab.activeBackground": "#231e17",
"tab.activeForeground": "#ece8e3",
"tab.activeBorderTop": "#b07a00",
"tab.inactiveBackground": "#302a20",
"tab.inactiveForeground": "#a69f96",
"tab.border": "#4a4035",
"input.background": "#231e17",
"input.foreground": "#ece8e3",
"input.border": "#4a4035",
"input.placeholderForeground": "#a69f9680",
"inputOption.activeBorder": "#b07a00",
"dropdown.background": "#3b3328",
"dropdown.foreground": "#ece8e3",
"dropdown.border": "#4a4035",
"list.activeSelectionBackground": "#3d3018",
"list.activeSelectionForeground": "#ece8e3",
"list.inactiveSelectionBackground": "#3d301880",
"list.hoverBackground": "#302a2080",
"list.highlightForeground": "#b07a00",
"scrollbarSlider.background": "#4a403540",
"scrollbarSlider.hoverBackground": "#4a403580",
"scrollbarSlider.activeBackground": "#4a4035a0",
"focusBorder": "#b07a0080",
"peekView.border": "#b07a00",
"peekViewEditor.background": "#231e17",
"peekViewResult.background": "#302a20",
"peekViewTitle.background": "#302a20",
"minimap.selectionHighlight": "#3d3018",
"minimap.findMatchHighlight": "#b07a0060",
},
"editor.fontSize": 14,
"editor.fontFamily": "'JetBrains Mono Variable', 'Fira Code', monospace",
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.minimap.enabled": false,
"editor.scrollBeyondLastLine": false,
"editor.wordWrap": "off",
"editor.renderWhitespace": "selection",
"editor.bracketPairColorization.enabled": true,
"editor.padding.top": 8,
"editor.lineNumbers": "on",
"editor.guides.bracketPairsHorizontal": "active",
"editor.wordBasedSuggestions": "off",
"editor.quickSuggestions": true,
"editor.parameterHints.enabled": true,
}),
},
monacoWorkerFactory: configureDefaultWorkerFactory,
};
}
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",
cfg: "ini",
sh: "shellscript",
};
return map[ext ?? ""] ?? "plaintext";
}
interface MonacoEditorProps {
filePath: string;
content: string;
language?: string;
onChange?: (value: string) => void;
workspacePath?: string;
}
export function MonacoEditor({
filePath,
content,
language,
onChange,
workspacePath,
}: MonacoEditorProps) {
const resolvedLang = language ?? languageFromPath(filePath);
const isNwscript = resolvedLang === "nwscript";
const fileUri = workspacePath
? `file://${workspacePath}/repos/nwn-module/${filePath}`
: `file:///workspace/repos/nwn-module/${filePath}`;
const editorAppConfig = useMemo<EditorAppConfig>(
() => ({
codeResources: {
modified: {
text: content,
uri: fileUri,
enforceLanguageId: resolvedLang,
},
},
editorOptions: {
automaticLayout: true,
},
}),
[filePath],
);
const languageClientConfig = useMemo<LanguageClientConfig | undefined>(() => {
if (!isNwscript) return undefined;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return {
languageId: "nwscript",
connection: {
options: {
$type: "WebSocketUrl" as const,
url: `${protocol}//${window.location.host}/ws/lsp`,
},
},
clientOptions: {
documentSelector: ["nwscript"],
workspaceFolder: workspacePath
? {
index: 0,
name: "nwn-module",
uri: { scheme: "file", path: `${workspacePath}/repos/nwn-module` } as any,
}
: undefined,
},
};
}, [isNwscript, workspacePath]);
const handleTextChanged = useCallback(
(textChanges: TextContents) => {
if (textChanges.modified !== undefined) {
onChange?.(textChanges.modified);
}
},
[onChange],
);
const handleError = useCallback((error: Error) => {
console.error("[MonacoEditor]", error.message, error.stack);
}, []);
return (
<MonacoEditorReactComp
style={{ width: "100%", height: "100%" }}
vscodeApiConfig={getVscodeApiConfig()}
editorAppConfig={editorAppConfig}
languageClientConfig={languageClientConfig}
onTextChanged={handleTextChanged}
onError={handleError}
logLevel={LogLevel.Warning}
/>
);
}