Add support for real-time editing in markdown
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing

This commit is contained in:
Garrett Mills 2021-04-24 11:42:00 -05:00
parent 5319af6fe9
commit 8db18c9315
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246
4 changed files with 231 additions and 6 deletions

View File

@ -7,7 +7,7 @@ import {EditorComponent} from 'ngx-monaco-editor';
import * as MonacoCollabExt from '@convergencelabs/monaco-collab-ext'; import * as MonacoCollabExt from '@convergencelabs/monaco-collab-ext';
import {FlitterSocketConnection, FlitterSocketServerClientTransaction} from '../../../flitter-socket'; import {FlitterSocketConnection, FlitterSocketServerClientTransaction} from '../../../flitter-socket';
import {debug} from '../../../utility'; import {debug} from '../../../utility';
import {environment} from "../../../../environments/environment"; import {environment} from '../../../../environments/environment';
export interface RemoteUser { export interface RemoteUser {
uuid: string; uuid: string;
@ -22,6 +22,7 @@ export interface RemoteInsertOperation {
type: 'insert'; type: 'insert';
index: number; index: number;
text: string; text: string;
fullContents?: string;
} }
export interface RemoteReplaceOperation { export interface RemoteReplaceOperation {
@ -29,12 +30,14 @@ export interface RemoteReplaceOperation {
index: number; index: number;
text: string; text: string;
length: number; length: number;
fullContents?: string;
} }
export interface RemoteDeleteOperation { export interface RemoteDeleteOperation {
type: 'delete'; type: 'delete';
index: number; index: number;
length: number; length: number;
fullContents?: string;
} }
export type RemoteOperation = RemoteInsertOperation | RemoteReplaceOperation | RemoteDeleteOperation; export type RemoteOperation = RemoteInsertOperation | RemoteReplaceOperation | RemoteDeleteOperation;

View File

@ -9,4 +9,4 @@
></ngx-monaco-editor> ></ngx-monaco-editor>
</div> </div>
<div class="display markdown-display" markdown katex [katexOptions]="katexOptions" [data]="contents" *ngIf="!showEditor || (showEditor && !singleColumn)"></div> <div class="display markdown-display" markdown katex [katexOptions]="katexOptions" [data]="contents" *ngIf="!showEditor || (showEditor && !singleColumn)"></div>
</div> </div>

View File

@ -3,8 +3,12 @@ import {EditorNodeContract} from '../EditorNode.contract';
import {EditorService} from '../../../service/editor.service'; import {EditorService} from '../../../service/editor.service';
import {v4} from 'uuid'; import {v4} from 'uuid';
import {EditorComponent} from 'ngx-monaco-editor'; import {EditorComponent} from 'ngx-monaco-editor';
import {KatexOptions, MarkedOptions} from 'ngx-markdown'; import {KatexOptions} from 'ngx-markdown';
import * as hljs from 'highlight.js'; 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({ @Component({
selector: 'editor-markdown', selector: 'editor-markdown',
@ -33,6 +37,17 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit {
protected hadOneFocusOut = false; protected hadOneFocusOut = false;
public singleColumn = false; public singleColumn = false;
public containerHeight = 540; 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 = { public editorOptions = {
theme: this.isDark() ? 'vs-dark' : 'vs', theme: this.isDark() ? 'vs-dark' : 'vs',
@ -73,6 +88,26 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit {
this.contents = this.initialValue; 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) { onMonacoEditorInit(editor) {
@ -91,6 +126,96 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit {
editor.onDidContentSizeChange(updateHeight); editor.onDidContentSizeChange(updateHeight);
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<boolean> { public isDirty(): boolean | Promise<boolean> {
@ -112,6 +237,18 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit {
onFocusIn() { onFocusIn() {
this.showEditor = this.editorService.canEdit(); 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']) @HostListener('document:keyup.escape', ['$event'])
@ -122,6 +259,16 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit {
this.hadOneFocusOut = false; this.hadOneFocusOut = false;
}, 500); }, 500);
} else { } 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.hadOneFocusOut = false;
this.showEditor = false; this.showEditor = false;
} }
@ -134,4 +281,81 @@ export class MarkdownComponent extends EditorNodeContract implements OnInit {
this.singleColumn = false; 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);
}
}
} }

View File

@ -82,7 +82,6 @@ export class FlitterSocketTransaction {
} }
this.sent = true; this.sent = true;
console.log('Sending message...', this.json);
return this.socket.send(this.json); return this.socket.send(this.json);
} }
} }
@ -176,7 +175,6 @@ export class FlitterSocketConnection {
} }
_create_socket() { _create_socket() {
console.log('Connecting to socket:', this.url);
this.socket = new _FWS(this.url); this.socket = new _FWS(this.url);
this.socket.onopen = (e) => { this.socket.onopen = (e) => {
this.open = true; this.open = true;