From 8db18c9315a9e3eca9b51535ac16e9a626462f05 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Sat, 24 Apr 2021 11:42:00 -0500 Subject: [PATCH] Add support for real-time editing in markdown --- .../components/editor/code/code.component.ts | 5 +- .../nodes/markdown/markdown.component.html | 2 +- .../nodes/markdown/markdown.component.ts | 228 +++++++++++++++++- src/app/flitter-socket.ts | 2 - 4 files changed, 231 insertions(+), 6 deletions(-) diff --git a/src/app/components/editor/code/code.component.ts b/src/app/components/editor/code/code.component.ts index 3f5c1fc..060571a 100644 --- a/src/app/components/editor/code/code.component.ts +++ b/src/app/components/editor/code/code.component.ts @@ -7,7 +7,7 @@ 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"; +import {environment} from '../../../../environments/environment'; export interface RemoteUser { uuid: string; @@ -22,6 +22,7 @@ export interface RemoteInsertOperation { type: 'insert'; index: number; text: string; + fullContents?: string; } export interface RemoteReplaceOperation { @@ -29,12 +30,14 @@ export interface RemoteReplaceOperation { 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; diff --git a/src/app/components/nodes/markdown/markdown.component.html b/src/app/components/nodes/markdown/markdown.component.html index cf6ce2e..d29d7ed 100644 --- a/src/app/components/nodes/markdown/markdown.component.html +++ b/src/app/components/nodes/markdown/markdown.component.html @@ -9,4 +9,4 @@ >
- \ No newline at end of file + diff --git a/src/app/components/nodes/markdown/markdown.component.ts b/src/app/components/nodes/markdown/markdown.component.ts index 6ce51d9..6fbaede 100644 --- a/src/app/components/nodes/markdown/markdown.component.ts +++ b/src/app/components/nodes/markdown/markdown.component.ts @@ -3,8 +3,12 @@ import {EditorNodeContract} from '../EditorNode.contract'; import {EditorService} from '../../../service/editor.service'; import {v4} from 'uuid'; import {EditorComponent} from 'ngx-monaco-editor'; -import {KatexOptions, MarkedOptions} from 'ngx-markdown'; -import * as hljs from 'highlight.js'; +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', @@ -33,6 +37,17 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit { 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', @@ -73,6 +88,26 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit { 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) { @@ -91,6 +126,96 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit { 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 { @@ -112,6 +237,18 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit { 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']) @@ -122,6 +259,16 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit { 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; } @@ -134,4 +281,81 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit { 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); + } + } } diff --git a/src/app/flitter-socket.ts b/src/app/flitter-socket.ts index 31532d8..9dedb1f 100644 --- a/src/app/flitter-socket.ts +++ b/src/app/flitter-socket.ts @@ -82,7 +82,6 @@ export class FlitterSocketTransaction { } this.sent = true; - console.log('Sending message...', this.json); return this.socket.send(this.json); } } @@ -176,7 +175,6 @@ export class FlitterSocketConnection { } _create_socket() { - console.log('Connecting to socket:', this.url); this.socket = new _FWS(this.url); this.socket.onopen = (e) => { this.open = true;