import {Component, ElementRef, Input, OnDestroy, OnInit, ViewChild} from '@angular/core'; import {v4} from 'uuid'; import {ApiService, ResourceNotAvailableOfflineError} from '../../../service/api.service'; import {EditorNodeContract} from '../../nodes/EditorNode.contract'; import {EditorService} from '../../../service/editor.service'; import {EditorComponent} from 'ngx-monaco-editor'; import * as MonacoCollabExt from '@convergencelabs/monaco-collab-ext'; import {FlitterSocketConnection, FlitterSocketServerClientTransaction} from '../../../flitter-socket'; import {debug} from '../../../utility'; import {environment} from '../../../../environments/environment'; export interface RemoteUser { uuid: string; uid: string; display: string; color: string; cursor?: any; selection?: any; } export interface RemoteInsertOperation { type: 'insert'; index: number; text: string; fullContents?: string; } export interface RemoteReplaceOperation { type: 'replace'; index: number; text: string; length: number; fullContents?: string; } export interface RemoteDeleteOperation { type: 'delete'; index: number; length: number; fullContents?: string; } export type RemoteOperation = RemoteInsertOperation | RemoteReplaceOperation | RemoteDeleteOperation; @Component({ selector: 'editor-code', templateUrl: './code.component.html', styleUrls: ['./code.component.scss'], }) export class CodeComponent extends EditorNodeContract implements OnInit, OnDestroy { @Input() nodeId: string; @Input() editorUUID?: string; @ViewChild('theEditor') theEditor: EditorComponent; @ViewChild('editorContainer') editorContainer: ElementRef; public dirty = false; protected dbRecord: any = {}; protected codeRefId!: string; public notAvailableOffline = false; public containerHeight = 540; protected cursorManager?: MonacoCollabExt.RemoteCursorManager; protected selectionManager?: MonacoCollabExt.RemoteSelectionManager; protected contentManager?: MonacoCollabExt.EditorContentManager; protected remoteUsers: RemoteUser[] = []; protected localUser?: RemoteUser; protected socket?: FlitterSocketConnection; protected editorGroupID!: string; public editorOptions = { theme: this.isDark() ? 'vs-dark' : 'vs', language: 'javascript', uri: v4(), readOnly: false, automaticLayout: true, scrollBeyondLastLine: false, scrollbar: { alwaysConsumeMouseWheel: false, }, }; public editorValue = ''; public get readonly() { return !this.node || !this.editorService.canEdit(); } public languageOptions: Array = [ 'ABAP', 'AES', 'Apex', 'AZCLI', 'Bat', 'C', 'Cameligo', 'Clojure', 'CoffeeScript', 'Cpp', 'Csharp', 'CSP', 'CSS', 'Dockerfile', 'Fsharp', 'Go', 'GraphQL', 'Handlebars', 'HTML', 'INI', 'Java', 'JavaScript', 'JSON', 'Kotlin', 'LeSS', 'Lua', 'Markdown', 'MiPS', 'MSDAX', 'MySQL', 'Objective-C', 'Pascal', 'Pascaligo', 'Perl', 'pgSQL', 'PHP', 'Plaintext', 'Postiats', 'PowerQuery', 'PowerShell', 'Pug', 'Python', 'R', 'Razor', 'Redis', 'RedShift', 'RestructuredText', 'Ruby', 'Rust', 'SB', 'Scheme', 'SCSS', 'Shell', 'SOL', 'SQL', 'St', 'Swift', 'TCL', 'Twig', 'TypeScript', 'VB', 'XML', 'YAML', ]; protected hadLoad = false; constructor( public editorService: EditorService, public readonly api: ApiService, ) { super(); } public isDark() { return document.body.classList.contains('dark'); } public isDirty(): boolean | Promise { return this.dirty; } public needsSave(): boolean | Promise { return this.dirty; } public writeChangesToNode(): void | Promise { this.node.Value.Mode = 'code'; this.node.Value.Value = this.codeRefId; this.node.value = this.codeRefId; } public needsLoad(): boolean | Promise { return this.node && !this.hadLoad; } public performLoad(): void | Promise { return new Promise((res, rej) => { if ( !this.node.Value ) { this.node.Value = {}; } if ( !this.node.Value.Value && this.editorService.canEdit() ) { this.api.createCodium(this.page.UUID, this.node.UUID).then(data => { this.dbRecord = data; this.node.Value.Mode = 'code'; this.node.Value.Value = data.UUID; this.node.value = data.UUID; this.codeRefId = data.UUID; this.editorOptions.readOnly = this.readonly; this.onSelectChange(false); this.hadLoad = true; this.notAvailableOffline = false; res(); }).catch(rej); } else { this.api.getCodium(this.page.UUID, this.node.UUID, this.node.Value.Value, this.node.associatedTypeVersionNum).then(data => { this.dbRecord = data; this.initialValue = this.dbRecord.code; this.editorValue = this.dbRecord.code; this.editorOptions.language = this.dbRecord.Language; this.codeRefId = this.node.Value.Value; this.editorOptions.readOnly = this.readonly; this.onSelectChange(false); this.hadLoad = true; this.notAvailableOffline = false; res(); }).catch(e => { if ( e instanceof ResourceNotAvailableOfflineError ) { this.notAvailableOffline = true; } else { rej(e); } }); } }); } public performSave(): void | Promise { if ( !this.editorService.canEdit() ) { return; } return new Promise((res, rej) => { this.dbRecord.code = this.editorValue; this.dbRecord.Language = this.editorOptions.language; this.api.saveCodium(this.page.UUID, this.node.UUID, this.node.Value.Value, this.dbRecord).then(data => { this.dbRecord = data; this.editorOptions.language = this.dbRecord.Language; this.editorValue = this.dbRecord.code; this.dirty = false; res(); }).catch(rej); }); } public performDelete(): void | Promise { return this.api.deleteCodium(this.page.UUID, this.node.UUID, this.node.Value.Value); } ngOnInit() { this.editorService = this.editorService.getEditor(this.editorUUID); this.editorService.registerNodeEditor(this.nodeId, this).then(() => { this.editorOptions.readOnly = !this.editorService.canEdit(); }); const url = `${environment.websocketBase}/api/v1/socket/code/.websocket`; debug(`Editor socket URL: ${url}`); if ( !this.editorService.isVersion() ) { const socket = new FlitterSocketConnection(url); socket.controller(this); socket.on_open().then(() => { debug('Connected to code editor socket', socket); socket.asyncRequest('subscribe', { resource_id: this.node.Value.Value }).then(([transaction, _, data]) => { debug('Subscribed to editor group:', data); if ( data.editor_group_id ) { this.editorGroupID = data.editor_group_id; this.socket = socket; this.localUser = data.local_user; } }); }); } } ngOnDestroy() { if ( this.socket ) { this.socket.socket.close(); } } onMonacoEditorInit(editor) { let ignoreEvent = false; const updateHeight = () => { const contentHeight = Math.max(540, editor.getContentHeight()); this.containerHeight = contentHeight; try { ignoreEvent = true; editor.layout({ width: this.editorContainer.nativeElement.offsetWidth, height: contentHeight }); } finally { ignoreEvent = false; } }; editor.onDidChangeCursorPosition(event => { this.socket?.asyncRequest('update_cursor', { position: event.position, uuid: this.localUser?.uuid, editor_group_id: this.editorGroupID, }); }); editor.onDidChangeCursorSelection(event => { this.socket?.asyncRequest('update_selection', { startPosition: { lineNumber: event.selection.startLineNumber, column: event.selection.startColumn, }, endPosition: { lineNumber: event.selection.endLineNumber, column: event.selection.endColumn, }, uuid: this.localUser?.uuid, editor_group_id: this.editorGroupID, }); }); editor.onDidContentSizeChange(updateHeight); updateHeight(); this.cursorManager = new MonacoCollabExt.RemoteCursorManager({ editor, tooltips: true, tooltipDuration: 2, }); this.selectionManager = new MonacoCollabExt.RemoteSelectionManager({ editor }); this.contentManager = new MonacoCollabExt.EditorContentManager({ editor, onInsert: (index, text) => { if ( this.readonly ) { return; } this.socket?.asyncRequest('apply', { editor_group_id: this.editorGroupID, operations: [ { type: 'insert', index, text, }, ], }); }, onReplace: (index, length, text) => { if ( this.readonly ) { return; } this.socket?.asyncRequest('apply', { editor_group_id: this.editorGroupID, operations: [ { type: 'replace', index, text, length, }, ], }); }, onDelete: (index, length) => { if ( this.readonly ) { return; } this.socket?.asyncRequest('apply', { editor_group_id: this.editorGroupID, operations: [ { type: 'delete', index, length, }, ], }); }, }); } applyOperation(op: RemoteOperation) { if ( op.type === 'insert' ) { this.contentManager?.insert(op.index, op.text); } else if ( op.type === 'replace' ) { this.contentManager?.replace(op.index, op.length, op.text); } else if ( op.type === 'delete' ) { this.contentManager?.delete(op.index, op.length); } } async applyRemoteOperation(transaction: FlitterSocketServerClientTransaction, connection: FlitterSocketConnection) { const ops: RemoteOperation[] = transaction.incoming.operations || []; for ( const op of ops ) { this.applyOperation(op); } } async updateCursorPosition(transaction: FlitterSocketServerClientTransaction, connection: FlitterSocketConnection) { const position = transaction.incoming.position; const uuid = transaction.incoming.uuid; for ( const user of this.remoteUsers ) { if ( user.uuid === uuid ) { user.cursor?.setPosition(position); } } } async updateSelection(transaction: FlitterSocketServerClientTransaction, connection: FlitterSocketConnection) { const startPosition = transaction.incoming.startPosition; const endPosition = transaction.incoming.endPosition; const uuid = transaction.incoming.uuid; for ( const user of this.remoteUsers ) { if ( user.uuid === uuid ) { if ( startPosition && endPosition ) { user.selection?.setPositions(startPosition, endPosition); user.selection?.show(); } else { user.selection?.hide(); } } } } async setEditorGroupUsers(transaction: FlitterSocketServerClientTransaction, connection: FlitterSocketConnection) { const remoteUsers: RemoteUser[] = Array.isArray(transaction.incoming?.users) ? transaction.incoming.users : []; console.log('set editor group users', remoteUsers, transaction); for ( const user of this.remoteUsers ) { this.cursorManager?.removeCursor(user.uuid); user.cursor?.dispose(); delete user.cursor; this.selectionManager?.removeSelection(user.uuid); user.selection?.dispose(); delete user.selection; } while ( !this.cursorManager ) { await new Promise(r => setTimeout(r, 500)); } this.remoteUsers = remoteUsers; for ( const user of this.remoteUsers ) { user.cursor = this.cursorManager?.addCursor(user.uuid, user.color, user.display); user.cursor?.setOffset(0); user.cursor?.show(); user.selection = this.selectionManager?.addSelection(user.uuid, user.color); } } public onEditorModelChange($event) { if ( this.editorValue !== this.dbRecord.code ) { this.dirty = true; this.editorService.triggerSave(); } } public onSelectChange(updateDbRecord = true) { if ( updateDbRecord ) { this.dbRecord.Language = this.editorOptions.language; this.editorService.triggerSave(); this.dirty = true; } this.editorOptions = {...this.editorOptions}; } }