diff --git a/.nx/cache/18.3.4-nx.linux-x64-gnu.node b/.nx/cache/18.3.4-nx.linux-x64-gnu.node new file mode 100644 index 0000000..29fb952 Binary files /dev/null and b/.nx/cache/18.3.4-nx.linux-x64-gnu.node differ diff --git a/package-lock.json b/package-lock.json index f140234..b4f06de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@angular/platform-browser": "^17.3.7", "@angular/platform-browser-dynamic": "^17.3.7", "@angular/router": "^17.3.7", + "@codemirror/collab": "^6.1.1", "@codemirror/lang-cpp": "^6.0.2", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/state": "^6.4.1", @@ -2447,6 +2448,15 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@codemirror/collab": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@codemirror/collab/-/collab-6.1.1.tgz", + "integrity": "sha512-tkIn9Jguh98ie12dbBuba3lE8LHUkaMrIFuCVeVGhncSczFdKmX25vC12+58+yqQW5AXi3py6jWY0W+jelyglA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0" + } + }, "node_modules/@codemirror/commands": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.5.0.tgz", diff --git a/package.json b/package.json index afaa066..d23a824 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@angular/platform-browser": "^17.3.7", "@angular/platform-browser-dynamic": "^17.3.7", "@angular/router": "^17.3.7", + "@codemirror/collab": "^6.1.1", "@codemirror/lang-cpp": "^6.0.2", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/state": "^6.4.1", diff --git a/src/app/app-routing.module.ts b/src/app/app.routes.ts similarity index 81% rename from src/app/app-routing.module.ts rename to src/app/app.routes.ts index 2ec202c..e0ca603 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app.routes.ts @@ -1,6 +1,6 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { Routes } from '@angular/router'; import { EditorComponent } from './components/editor/editor.component'; +import { EditorLiveComponent } from './components/editor-live/editor-live.component'; import { LandingPageComponent } from './components/landing-page/landing-page.component'; import { DocumentationComponent } from './components/documentation/documentation.component'; import { FormComponent } from './components/form/form.component'; @@ -9,18 +9,13 @@ import { OurStoryComponent } from './components/our-story/our-story.component'; import { PrivacyPolicyComponent } from './components/privacy-policy/privacy-policy.component'; // Toutes les routes de l'application sont définies ici -const routes: Routes = [ +export const routes: Routes = [ { path: '', component: LandingPageComponent }, { path: 'editor', component: EditorComponent }, + { path: 'editor-live/:idRoom', component: EditorLiveComponent }, { path: 'documentation', component: DocumentationComponent }, { path: 'contact', component: FormComponent }, { path: 'our-story', component: OurStoryComponent }, { path: 'terms-of-service', component: TermsOfServiceComponent }, { path: 'privacy-policy', component: PrivacyPolicyComponent }, ]; - -@NgModule({ - imports: [RouterModule.forRoot(routes)], - exports: [RouterModule], -}) -export class AppRoutingModule {} diff --git a/src/app/components/editor-live/editor-live.component.html b/src/app/components/editor-live/editor-live.component.html new file mode 100644 index 0000000..e1a834e --- /dev/null +++ b/src/app/components/editor-live/editor-live.component.html @@ -0,0 +1,115 @@ +
+
+
+
+ + +
+ +
+ +
+
+
+ @if (errorMessage) { +
+

{{ errorMessage }}

+
+ } + + + + + +
+ +
+
+
+ +
+
+ + +
+
+

+    
+
+
diff --git a/src/app/components/editor-live/editor-live.component.scss b/src/app/components/editor-live/editor-live.component.scss new file mode 100644 index 0000000..3e19580 --- /dev/null +++ b/src/app/components/editor-live/editor-live.component.scss @@ -0,0 +1,85 @@ +#editor-bar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + color: #fff; +} + +.editor-section-bar-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.button-icon { + border: 0; + background: transparent; + cursor: pointer; +} + +svg { + width: 30px; + height: auto; + aspect-ratio: 1; + cursor: pointer; +} + +.button-run { + display: flex; + align-items: center; + justify-content: space-between; + background-color: #04aa6d; + border: none; + color: white; + padding: 12px 16px; + font-size: 16px; + width: 100px; + cursor: pointer; + border-radius: 10px; +} + +.button-join { + background-color: #1c53bf; + border: none; + color: white; + padding: 12px 16px; + font-size: 16px; + width: 100px; + cursor: pointer; + border-radius: 10px; +} + +select { + background-color: #0000f0; + border: none; + color: white; + padding: 12px 16px; + font-size: 16px; + cursor: pointer; + border-radius: 10px; +} + +/*editor*/ + +.editor-center { + height: 700px; + display: flex; + align-items: center; + justify-content: space-between; + margin: 0px 0px 0px 0px; +} + +.editor-child-element { + min-height: 100px; + height: 100%; + width: 1000px; + background-color: black; + color: #fff; + border-right: 10px solid gray; +} + +::ng-deep .codemirror6-editor { + height: 100%; +} diff --git a/src/app/components/editor-live/editor-live.component.spec.ts b/src/app/components/editor-live/editor-live.component.spec.ts new file mode 100644 index 0000000..aec1b0b --- /dev/null +++ b/src/app/components/editor-live/editor-live.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EditorLiveComponent } from './editor-live.component'; + +describe('EditorLiveComponent', () => { + let component: EditorLiveComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [EditorLiveComponent], + }); + fixture = TestBed.createComponent(EditorLiveComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/editor-live/editor-live.component.ts b/src/app/components/editor-live/editor-live.component.ts new file mode 100644 index 0000000..4fea997 --- /dev/null +++ b/src/app/components/editor-live/editor-live.component.ts @@ -0,0 +1,328 @@ +import { Component, Input, ViewChild } from '@angular/core'; +import { CodeExecutionService } from 'src/app/services/codeExecution.service'; +import { Compartment, StateEffect } from '@codemirror/state'; +import { CodeMirrorComponent } from '@sandkasten/codemirror6-editor'; +import { LanguageDescription } from '@codemirror/language'; +import { CODE_DEFAULTS, LANGUAGES } from '../languages'; +import { SafeHTMLPipe } from '../../safe-html.pipe'; +import { ReactiveFormsModule, FormsModule } from '@angular/forms'; +import { + keymap, + highlightSpecialChars, + drawSelection, + highlightActiveLine, + dropCursor, + rectangularSelection, + crosshairCursor, + lineNumbers, + highlightActiveLineGutter, + gutter, +} from '@codemirror/view'; +import { Extension, EditorState } from '@codemirror/state'; +import { + defaultHighlightStyle, + syntaxHighlighting, + indentOnInput, + bracketMatching, + foldGutter, + foldKeymap, +} from '@codemirror/language'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { + autocompletion, + completionKeymap, + closeBrackets, + closeBracketsKeymap, +} from '@codemirror/autocomplete'; +import { lintKeymap } from '@codemirror/lint'; + +const basicSetup: Extension = (() => [ + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + foldGutter(), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + bracketMatching(), + closeBrackets(), + autocompletion(), + rectangularSelection(), + crosshairCursor(), + highlightActiveLine(), + highlightSelectionMatches(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + ...completionKeymap, + ...lintKeymap, + ]), +])(); + +@Component({ + selector: 'app-editor-live', + templateUrl: './editor-live.component.html', + styleUrls: ['./editor-live.component.scss'], + standalone: true, + imports: [ + CodeMirrorComponent, + ReactiveFormsModule, + FormsModule, + SafeHTMLPipe, + ], +}) +export class EditorLiveComponent { + isLoaded: boolean = false; // Pour vérifier si le chargement est terminé + + + readonly languages: LanguageDescription[] = LANGUAGES; + // Mode par défaut + private _selectedLanguage = this.languages.find( + (lang) => lang.name === 'JavaScript' + )!; + get selectedLanguage(): LanguageDescription { + return this._selectedLanguage; + } + set selectedLanguage(value: LanguageDescription) { + this._selectedLanguage = value; + if (value.name in CODE_DEFAULTS) { + this.editorContent = + CODE_DEFAULTS[value.name as keyof typeof CODE_DEFAULTS]; + } + this.selectedLanguage.load().then((language) => { + this.codemirror.editor?.dispatch({ + effects: this.languageCompartment.reconfigure(language), + }); + }); + } + private _linesNumbers: boolean = true; + get linesNumbers() { + return this._linesNumbers; + } + set linesNumbers(lines: boolean) { + this._linesNumbers = lines; + this.codemirror.editor?.dispatch({ + effects: this.gutterCompartment.reconfigure( + lines ? lineNumbers() : gutter({}) + ), + }); + } + + // Contenu de l'éditeur que l'on passera au serveur + editorContent: string = + CODE_DEFAULTS[this.selectedLanguage.name as keyof typeof CODE_DEFAULTS]; + resultContent: string = ''; + + // Message d'erreur + errorMessage: string = ''; + + @ViewChild(CodeMirrorComponent) private codemirror!: CodeMirrorComponent; + + private readonly languageCompartment = new Compartment(); + private readonly gutterCompartment = new Compartment(); + protected readonly extensions: Extension[] = [ + basicSetup, + this.gutterCompartment.of(lineNumbers()), + this.languageCompartment.of(this.selectedLanguage.support!), + ]; + + private client: WebSocket | undefined; + @Input() + set idRoom(idRoom: string) { + this.client = new WebSocket(`ws://127.0.0.1:3000/live/${idRoom}`); + this.client.addEventListener('open', async () => { + let conn = new Connection(this.client!); + console.log('open') + let {version, doc} = await getDocument(conn); + console.log('res') + this.codemirror.editor?.dispatch({ + changes: { + from: 0, + to: this.codemirror.editor.state.doc.length, + insert: doc + } + }) + this.codemirror.editor?.dispatch({ + effects: StateEffect.appendConfig.of([peerExtension(version, conn)]), + }) + }); + } + + + constructor(private codeExecutionService: CodeExecutionService) {} + + // Efface le contenu de l'éditeur + clear(): void { + this.editorContent = ''; + } + + onRunButtonClicked() { + // Le code à exécuter est le contenu de l'éditeur + const codeToExecute = this.editorContent; + this.codeExecutionService.executeCode( + codeToExecute, + this.selectedLanguage.name + ); + + this.resultContent = ''; + } + + loadFromFile(event: Event) { + const file = (event.target as HTMLInputElement).files![0]; + for (const language of this.languages) { + if (language.extensions.some((ext) => file.name.endsWith(`.${ext}`))) { + this.selectedLanguage = language; + const reader = new FileReader(); + reader.onload = (event) => { + this.editorContent = event.target!.result as string; + this.errorMessage = ''; + }; + reader.readAsText(file); + return; + } + } + const extensions = this.languages.flatMap((lang) => lang.extensions); + this.errorMessage = `Unsupported language. Please select one of the following languages: ${extensions.join(', ')}.`; + console.error(this.errorMessage); + } + + saveToFile() { + const blob = new Blob([this.editorContent], { type: 'text/plain' }); + const a = document.createElement('a'); + a.download = `code.${this.selectedLanguage.extensions![0]}`; + a.href = URL.createObjectURL(blob); + a.click(); + } +} + +/*live*/ +import { ChangeSet, Text } from '@codemirror/state'; +import {EditorView} from "codemirror" +import { Update, collab, getSyncedVersion, receiveUpdates, sendableUpdates } from '@codemirror/collab'; +import { ViewPlugin, ViewUpdate } from '@codemirror/view'; + +class Connection { + private requestId = 0; + private resolves: Record void> = {}; + + constructor(private client: WebSocket) { + client.addEventListener('message', (event) => { + const response = JSON.parse(event.data); + if ('_request' in response) { + const resolve = this.resolves[response._request]; + if (resolve) { + resolve(response.payload); + } else { + console.error('Received response for unknown or already used request', response._request); + } + } else { + console.error('Received invalid response', response._request); + } + }) + } + + request(body: Record): Promise { + body['_request'] = this.requestId; + this.client.send(JSON.stringify(body)); + return new Promise((resolve) => this.resolves[this.requestId++] = resolve); + } +} + +function pushUpdates( + connection: Connection, + version: number, + fullUpdates: readonly Update[] +): Promise { + // Strip off transaction data + let updates = fullUpdates.map(u => ({ + clientID: u.clientID, + changes: u.changes.toJSON() + })); + console.log("test1"); + return connection.request({type: "pushUpdates", version, updates}); +} + +function pullUpdates( + connection: Connection, + version: number +): Promise { + console.log("test2"); + + return connection.request({type: "pullUpdates", version}) + .then(updates => updates.map((u: any) => ({ + changes: ChangeSet.fromJSON(u.changes), + clientID: u.clientID + }))); +} + +function getDocument( + connection: Connection +): Promise<{version: number, doc: Text}> { + console.log("test3"); + + return connection.request({ type: "getDocument" }).then(data => ({ + version: data.version, + doc: Text.of(data.doc.split("\n")) + })); +} + +function peerExtension(startVersion: number, connection: Connection) { + console.log(connection) + let plugin = ViewPlugin.fromClass(class { + private pushing = false + private done = false + + constructor(private view: EditorView) { this.pull() } + + update(update: ViewUpdate) { + if (update.docChanged) this.push() + } + + async push() { + let updates = sendableUpdates(this.view.state) + console.log("push"); + if (this.pushing || !updates.length) return + this.pushing = true + let version = getSyncedVersion(this.view.state) + await pushUpdates(connection, version, updates) + this.pushing = false + console.log("push2"); + + // Regardless of whether the push failed or new updates came in + // while it was running, try again if there's updates remaining + if (sendableUpdates(this.view.state).length) + setTimeout(() => this.push(), 100) + } + + async pull() { + while (!this.done) { + let version = getSyncedVersion(this.view.state) + let updates = await pullUpdates(connection, version) + console.log(updates) + this.view.dispatch(receiveUpdates(this.view.state, updates)) + } + } + + destroy() { this.done = true } + }) + return [collab({startVersion}), plugin] +} + +async function createPeer(connection: Connection) { + let {version, doc} = await getDocument(connection) + console.log(doc); + let state = EditorState.create({ + doc, + extensions: [basicSetup, peerExtension(version, connection)], + }) + return new EditorView({state, parent: document.body}) +} + +//const client = new WebSocket('ws://127.0.0.1:3000/live/test'); +//client.addEventListener('open', () => createPeer(new Connection(client))); \ No newline at end of file diff --git a/src/app/components/editor/editor.component.ts b/src/app/components/editor/editor.component.ts index d8793e5..540a284 100644 --- a/src/app/components/editor/editor.component.ts +++ b/src/app/components/editor/editor.component.ts @@ -176,4 +176,4 @@ export class EditorComponent { a.href = URL.createObjectURL(blob); a.click(); } -} +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 4d15ebd..504e152 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,15 +8,16 @@ import { HttpClient, } from '@angular/common/http'; import { ReactiveFormsModule, FormsModule } from '@angular/forms'; -import { AppRoutingModule } from './app/app-routing.module'; import { BrowserModule, bootstrapApplication } from '@angular/platform-browser'; import { TranslationService } from './app/services/translation.service'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { routes } from './app/app.routes'; bootstrapApplication(AppComponent, { providers: [ + provideRouter(routes, withComponentInputBinding()), importProvidersFrom( BrowserModule, - AppRoutingModule, ReactiveFormsModule, FormsModule, TranslateModule.forRoot({