Add support for real-time editing in markdown
This commit is contained in:
parent
5319af6fe9
commit
8db18c9315
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user