Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "./node_modules/monaco-editor/min",
"output": "/assets/monaco-editor/min"
}
],
"styles": [
Expand Down
6 changes: 6 additions & 0 deletions apps/frontend/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
29 changes: 29 additions & 0 deletions apps/frontend/src/app/app.css
Original file line number Diff line number Diff line change
@@ -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%;
}
}
19 changes: 16 additions & 3 deletions apps/frontend/src/app/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,22 @@
<div class="content">
<div class="left-side">
<img src="Scaffold-Rust-Logo.jpg" alt="" width="30">
<h1>Hello, awesome developer!</h1>
<p>Congratulations! Your app is running. 🎉</p>
<p>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]</p>
<h1>Online Soroban Compiler</h1>
<p>Write and test your Stellar smart contracts! 🚀</p>
<p>Use the Monaco Editor below to write Rust smart contracts for the Stellar blockchain using the Soroban SDK.</p>

<!-- Monaco Editor -->
<div class="editor-container">
<h3>Rust Smart Contract Editor</h3>
<app-monaco-editor
[language]="'rust'"
[theme]="'vs-dark'"
[height]="'500px'"
[options]="{ minimap: { enabled: false } }"
[(ngModel)]="rustCode"
(ngModelChange)="onEditorChange($event)">
</app-monaco-editor>
</div>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
Expand Down
16 changes: 15 additions & 1 deletion apps/frontend/src/app/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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');
});
});
28 changes: 27 additions & 1 deletion apps/frontend/src/app/app.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
207 changes: 207 additions & 0 deletions apps/frontend/src/app/monaco-editor/monaco-editor.component.ts
Original file line number Diff line number Diff line change
@@ -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: `<div #editorContainer style="height: 100%;"></div>`,
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<HTMLDivElement>;

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 });
}
}
}
Loading