From 1ee26fef002cad5bb56e391ec0873b85df4922c2 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Sun, 10 Aug 2025 00:51:23 +0100 Subject: [PATCH] feat: integrate monaco editor --- apps/frontend/angular.json | 5 + apps/frontend/bun.lock | 6 + apps/frontend/package.json | 2 + apps/frontend/src/app/app.css | 29 +++ apps/frontend/src/app/app.html | 19 +- apps/frontend/src/app/app.spec.ts | 16 +- apps/frontend/src/app/app.ts | 28 ++- .../monaco-editor/monaco-editor.component.ts | 207 ++++++++++++++++++ 8 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 apps/frontend/src/app/monaco-editor/monaco-editor.component.ts diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index 70a37c6..d41705f 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -19,6 +19,11 @@ { "glob": "**/*", "input": "public" + }, + { + "glob": "**/*", + "input": "./node_modules/monaco-editor/min", + "output": "/assets/monaco-editor/min" } ], "styles": [ diff --git a/apps/frontend/bun.lock b/apps/frontend/bun.lock index 413e4b1..0f22629 100644 --- a/apps/frontend/bun.lock +++ b/apps/frontend/bun.lock @@ -12,7 +12,9 @@ "@angular/platform-server": "^20.1.0", "@angular/router": "^20.1.0", "@angular/ssr": "^20.1.4", + "@materia-ui/ngx-monaco-editor": "^6.0.0", "express": "^5.1.0", + "monaco-editor": "^0.52.2", "rxjs": "~7.8.0", "tslib": "^2.3.0", }, @@ -284,6 +286,8 @@ "@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-HqqKIhTbq6piJhkJpTTf3w1m/CgrmwXRAL9R9j7Ru5xdZSeO7Mg4AWiBC9B00uXR+LvVZKtUyRMVZfhmIZztmQ=="], + "@materia-ui/ngx-monaco-editor": ["@materia-ui/ngx-monaco-editor@6.0.0", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@angular/core": ">=13.0.0", "rxjs": ">=6.0.0" } }, "sha512-gTqNQjOGznZxOC0NlmKdKSGCJuTts8YmK4dsTQAGc5IgIV7cZdQWiW6AL742h0ruED6q0cAunEYjXT6jzHBoIQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.13.3", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-bGwA78F/U5G2jrnsdRkPY3IwIwZeWUEfb5o764b79lb0rJmMT76TLwKhdNZOWakOQtedYefwIR4emisEMvInKA=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], @@ -1086,6 +1090,8 @@ "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": "bin/cmd.js" }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "monaco-editor": ["monaco-editor@0.52.2", "", {}, "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 64b34fb..d6e3649 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -32,7 +32,9 @@ "@angular/platform-server": "^20.1.0", "@angular/router": "^20.1.0", "@angular/ssr": "^20.1.4", + "@materia-ui/ngx-monaco-editor": "^6.0.0", "express": "^5.1.0", + "monaco-editor": "^0.52.2", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/apps/frontend/src/app/app.css b/apps/frontend/src/app/app.css index e69de29..8314a2c 100644 --- a/apps/frontend/src/app/app.css +++ b/apps/frontend/src/app/app.css @@ -0,0 +1,29 @@ +.editor-container { + margin-top: 2rem; + width: 100%; + max-width: 800px; +} + +.editor-container h3 { + color: var(--gray-900); + margin-bottom: 1rem; + font-size: 1.5rem; + font-weight: 500; +} + +.content { + flex-direction: column !important; + max-width: 1200px !important; + align-items: flex-start !important; +} + +.left-side { + width: 100% !important; + max-width: none !important; +} + +@media screen and (max-width: 650px) { + .editor-container { + width: 100%; + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/app.html b/apps/frontend/src/app/app.html index 5ccc1c1..d532c2d 100644 --- a/apps/frontend/src/app/app.html +++ b/apps/frontend/src/app/app.html @@ -186,9 +186,22 @@
-

Hello, awesome developer!

-

Congratulations! Your app is running. 🎉

-

We are very excited that you are contributing to this great project. This is just the Angular template, so everything needs to be modified. [including this text]

+

Online Soroban Compiler

+

Write and test your Stellar smart contracts! 🚀

+

Use the Monaco Editor below to write Rust smart contracts for the Stellar blockchain using the Soroban SDK.

+ + +
+

Rust Smart Contract Editor

+ + +
diff --git a/apps/frontend/src/app/app.spec.ts b/apps/frontend/src/app/app.spec.ts index 7b6fd55..41c26af 100644 --- a/apps/frontend/src/app/app.spec.ts +++ b/apps/frontend/src/app/app.spec.ts @@ -2,6 +2,20 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { App } from './app'; +// Mock Monaco Editor for tests +(globalThis as unknown as { monaco: unknown }).monaco = { + editor: { + create: () => ({ + getValue: () => '', + setValue: () => {}, + dispose: () => {}, + onDidChangeModelContent: () => {}, + updateOptions: () => {}, + layout: () => {} + }) + } +}; + describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ @@ -20,6 +34,6 @@ describe('App', () => { const fixture = TestBed.createComponent(App); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend'); + expect(compiled.querySelector('h1')?.textContent).toContain('Online Soroban Compiler'); }); }); diff --git a/apps/frontend/src/app/app.ts b/apps/frontend/src/app/app.ts index ade0fcb..c19266b 100644 --- a/apps/frontend/src/app/app.ts +++ b/apps/frontend/src/app/app.ts @@ -1,12 +1,38 @@ import { Component, signal } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { MonacoEditorComponent } from './monaco-editor/monaco-editor.component'; @Component({ selector: 'app-root', - imports: [RouterOutlet], + imports: [RouterOutlet, MonacoEditorComponent, FormsModule], templateUrl: './app.html', styleUrl: './app.css' }) export class App { protected readonly title = signal('frontend'); + protected readonly rustCode = signal(`// Sample Rust smart contract for Stellar +use soroban_sdk::{contract, contractimpl, log, Env, Symbol, symbol_short}; + +#[contract] +pub struct HelloContract; + +#[contractimpl] +impl HelloContract { + /// Says hello to someone + pub fn hello(env: Env, to: Symbol) -> Symbol { + log!(&env, "Hello {}", to); + symbol_short!("Hello") + } + + /// Returns a greeting + pub fn greet(env: Env, name: Symbol) -> Symbol { + log!(&env, "Greeting {}", name); + symbol_short!("Greet") + } +}`); + + onEditorChange(value: string) { + this.rustCode.set(value); + } } diff --git a/apps/frontend/src/app/monaco-editor/monaco-editor.component.ts b/apps/frontend/src/app/monaco-editor/monaco-editor.component.ts new file mode 100644 index 0000000..9ac7777 --- /dev/null +++ b/apps/frontend/src/app/monaco-editor/monaco-editor.component.ts @@ -0,0 +1,207 @@ +import { Component, Input, forwardRef, OnInit, OnDestroy, ViewEncapsulation, signal, effect, ViewChild, ElementRef, inject, PLATFORM_ID } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms'; +import { isPlatformBrowser } from '@angular/common'; + +interface MonacoEditor { + getValue(): string; + setValue(value: string): void; + dispose(): void; + onDidChangeModelContent(callback: () => void): void; + updateOptions(options: MonacoEditorOptions): void; + layout(): void; +} + +interface MonacoEditorOptions { + value?: string; + language?: string; + theme?: string; + minimap?: { enabled: boolean }; + automaticLayout?: boolean; + scrollBeyondLastLine?: boolean; + fontSize?: number; + wordWrap?: string; + lineNumbers?: string; + glyphMargin?: boolean; + folding?: boolean; + lineDecorationsWidth?: number; + lineNumbersMinChars?: number; + renderLineHighlight?: string; + contextmenu?: boolean; + mouseWheelZoom?: boolean; + readOnly?: boolean; +} + +interface WindowRequire { + config(config: { paths: { vs: string } }): void; + (modules: string[], callback: () => void): void; +} + +declare const monaco: { + editor: { + create(element: HTMLElement, options: MonacoEditorOptions): MonacoEditor; + }; +}; + +declare global { + interface Window { + require: WindowRequire; + } +} + +@Component({ + selector: 'app-monaco-editor', + template: `
`, + styleUrls: [], + encapsulation: ViewEncapsulation.None, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MonacoEditorComponent), + multi: true + } + ], + imports: [FormsModule], + standalone: true +}) +export class MonacoEditorComponent implements OnInit, OnDestroy, ControlValueAccessor { + @Input() language = 'rust'; + @Input() theme = 'vs-dark'; + @Input() height = '500px'; + @Input() options: MonacoEditorOptions = {}; + + @ViewChild('editorContainer', { static: true }) editorContainer!: ElementRef; + + private editor: MonacoEditor | null = null; + private currentValue = signal(''); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private onChange = (_value: string) => { /* no-op */ }; + private onTouched = () => {}; + + private platformId = inject(PLATFORM_ID); + + constructor() { + effect(() => { + if (this.editor && this.currentValue() !== this.editor.getValue()) { + this.editor.setValue(this.currentValue()); + } + }); + } + + ngOnInit() { + this.loadMonacoEditor(); + } + + ngOnDestroy() { + if (this.editor) { + this.editor.dispose(); + } + } + + private loadMonacoEditor() { + // Only load Monaco Editor in the browser + if (!isPlatformBrowser(this.platformId)) { + return; + } + + // Load Monaco Editor + if (typeof monaco === 'undefined') { + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = '/assets/monaco-editor/min/vs/loader.js'; + script.onload = () => { + window.require.config({ + paths: { vs: '/assets/monaco-editor/min/vs' } + }); + window.require(['vs/editor/editor.main'], () => { + this.initEditor(); + }); + }; + script.onerror = () => { + this.loadFromCDN(); + }; + document.getElementsByTagName('head')[0].appendChild(script); + } else { + this.initEditor(); + } + } + + private loadFromCDN() { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const script = document.createElement('script'); + script.src = 'https://unpkg.com/monaco-editor@latest/min/vs/loader.js'; + script.onload = () => { + window.require.config({ + paths: { vs: 'https://unpkg.com/monaco-editor@latest/min/vs' } + }); + window.require(['vs/editor/editor.main'], () => { + this.initEditor(); + }); + }; + document.head.appendChild(script); + } + + private initEditor() { + const editorElement = this.editorContainer.nativeElement; + if (!editorElement) { + setTimeout(() => this.initEditor(), 100); + return; + } + + const defaultOptions: MonacoEditorOptions = { + value: this.currentValue(), + language: this.language, + theme: this.theme, + minimap: { enabled: false }, + automaticLayout: true, + scrollBeyondLastLine: false, + fontSize: 14, + wordWrap: 'on', + lineNumbers: 'on', + glyphMargin: false, + folding: true, + lineDecorationsWidth: 20, + lineNumbersMinChars: 3, + renderLineHighlight: 'line', + contextmenu: true, + mouseWheelZoom: true, + ...this.options + }; + + this.editor = monaco.editor.create(editorElement, defaultOptions); + + // Set up change listener + this.editor.onDidChangeModelContent(() => { + const value = this.editor?.getValue() || ''; + this.currentValue.set(value); + this.onChange(value); + this.onTouched(); + }); + + // Set height + editorElement.style.height = this.height; + this.editor.layout(); + } + + // ControlValueAccessor implementation + writeValue(value: string): void { + this.currentValue.set(value || ''); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + if (this.editor) { + this.editor.updateOptions({ readOnly: isDisabled }); + } + } +} \ No newline at end of file