import {Component, ElementRef, HostListener, Input, OnInit, ViewChild} from '@angular/core'; import {EditorNodeContract} from '../EditorNode.contract'; import {EditorService} from '../../../service/editor.service'; import {v4} from 'uuid'; import {EditorComponent} from 'ngx-monaco-editor'; import {KatexOptions} from 'ngx-markdown'; import {environment} from '../../../../environments/environment'; import {debug} from '../../../utility'; import {FlitterSocketConnection, FlitterSocketServerClientTransaction} from '../../../flitter-socket'; import {RemoteOperation, RemoteUser} from '../../editor/code/code.component'; import * as MonacoCollabExt from '@convergencelabs/monaco-collab-ext'; @Component({ selector: 'editor-markdown', templateUrl: './markdown.component.html', styleUrls: ['./markdown.component.scss'], }) export class MarkdownComponent extends EditorNodeContract implements OnInit { // @ViewChild('editable') editable; @Input() nodeId: string; @Input() editorUUID?: string; @ViewChild('editor') editor: EditorComponent; @ViewChild('editorContainer') editorContainer: ElementRef; public readonly katexOptions: KatexOptions = { // displayMode: true, // @ts-ignore output: 'mathml', }; // public isFocused = false; public initialValue = 'Double-click to edit...'; protected savedValue = 'Double-click to edit...'; public contents = ''; private dirtyOverride = false; public showEditor = false; protected hadOneFocusOut = false; public singleColumn = false; public containerHeight = 540; public editorGroupID!: string; protected socket?: FlitterSocketConnection; protected localUser!: RemoteUser; protected remoteUsers: RemoteUser[] = []; protected cursorManager: any; protected selectionManager: any; protected contentManager: any; public get readonly() { return !this.node || !this.editorService.canEdit(); } public editorOptions = { theme: this.isDark() ? 'vs-dark' : 'vs', language: 'markdown', uri: v4(), readOnly: false, automaticLayout: true, wordWrap: 'on', scrollBeyondLastLine: false, scrollbar: { alwaysConsumeMouseWheel: false, }, }; constructor( public editorService: EditorService, ) { super(); this.contents = this.initialValue; this.savedValue = this.initialValue; } public isDark() { return document.body.classList.contains('dark'); } ngOnInit() { this.editorService = this.editorService.getEditor(this.editorUUID); this.editorService.registerNodeEditor(this.nodeId, this).then(() => { if ( !this.node.Value ) { this.node.Value = {}; } if ( this.node.Value.Value ) { this.initialValue = this.node.Value.Value; this.savedValue = this.node.Value.Value; } this.contents = this.initialValue; }); const url = `${environment.websocketBase}/api/v1/socket/markdown/.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 markdown editor socket', socket); socket.asyncRequest('subscribe', { resource_id: this.node.UUID }).then(([transaction, _, data]) => { debug('Subscribed to markdown group:', data); if ( data.editor_group_id ) { this.editorGroupID = data.editor_group_id; this.socket = socket; this.localUser = data.local_user; } }); }); } } 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.onDidContentSizeChange(updateHeight); updateHeight(); 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, fullContents: this.contents, }, ], }); }, onReplace: (index, length, text) => { if ( this.readonly ) { return; } this.socket?.asyncRequest('apply', { editor_group_id: this.editorGroupID, operations: [ { type: 'replace', index, text, length, fullContents: this.contents, }, ], }); }, onDelete: (index, length) => { if ( this.readonly ) { return; } this.socket?.asyncRequest('apply', { editor_group_id: this.editorGroupID, operations: [ { type: 'delete', index, length, fullContents: this.contents, }, ], }); }, }); } public isDirty(): boolean | Promise { return this.dirtyOverride || this.contents !== this.savedValue; } public writeChangesToNode(): void | Promise { this.node.Value.Mode = 'markdown'; this.node.Value.Value = this.contents; this.node.value = this.contents; this.savedValue = this.contents; } onContentsChanged(event) { if ( event !== this.savedValue ) { this.editorService.triggerSave(); } } onFocusIn() { this.showEditor = this.editorService.canEdit(); requestAnimationFrame(() => { if ( this.showEditor ) { 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); } } }); } @HostListener('document:keyup.escape', ['$event']) onFocusOut(event) { if ( !this.hadOneFocusOut ) { this.hadOneFocusOut = true; setTimeout(() => { this.hadOneFocusOut = false; }, 500); } else { 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; } this.hadOneFocusOut = false; this.showEditor = false; } } onEditorHostResize($event) { if ( $event.newWidth < 700 && !this.singleColumn ) { this.singleColumn = true; } else if ( $event.newWidth > 700 && this.singleColumn ) { this.singleColumn = false; } } applyOperation(op: RemoteOperation) { if ( !this.showEditor ) { this.contents = op.fullContents ?? this.contents; return; } 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); } } }