Start adding real-time collab support to WYSIWYG
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Garrett Mills 2021-01-02 15:11:42 -06:00
parent 8f7ff1de73
commit c7f9a59cc4
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246
8 changed files with 629 additions and 13 deletions

14
capacitor.config.json Normal file
View File

@ -0,0 +1,14 @@
{
"appId": "io.ionic.starter",
"appName": "frontend",
"bundledWebRuntime": false,
"npmClient": "npm",
"webDir": "www",
"plugins": {
"SplashScreen": {
"launchShowDuration": 0
}
},
"cordova": {},
"linuxAndroidStudioPath": "/home/garrettmills/.local/android-studio/bin/studio.sh"
}

View File

@ -2,6 +2,8 @@
"/link_api": {
"target": "http://localhost:8000",
"secure": false,
"ws": true,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": {"^/link_api": ""}
}

View File

@ -2,6 +2,10 @@
<wysiwyg-editor
[contents]="contents"
(contentsChanged)="onContentsChanged($event)"
(selectionChanged)="onSelectionChanged($event)"
[readonly]="isReadonly"
[editingUsers]="editorGroupUsers"
[editingUserSelections]="editingUserSelections"
[requestSelectionRefresh]="requestSelectionRefresh"
></wysiwyg-editor>
</div>

View File

@ -1,13 +1,17 @@
import {Component, HostListener, Input, OnInit, ViewChild} from '@angular/core';
import {Component, HostListener, Input, OnDestroy, OnInit, ViewChild} from '@angular/core';
import {EditorNodeContract} from '../EditorNode.contract';
import {EditorService} from '../../../service/editor.service';
import {FlitterSocketConnection, FlitterSocketServerClientTransaction} from '../../../flitter-socket';
import {ApiService} from '../../../service/api.service';
import {debug} from '../../../utility';
import { EditingUserSelect } from '../../wysiwyg/wysiwyg.component';
@Component({
selector: 'editor-norm',
templateUrl: './norm.component.html',
styleUrls: ['./norm.component.scss'],
})
export class NormComponent extends EditorNodeContract implements OnInit {
export class NormComponent extends EditorNodeContract implements OnInit, OnDestroy {
@ViewChild('editable') editable;
@Input() nodeId: string;
@Input() editorUUID?: string;
@ -16,13 +20,23 @@ export class NormComponent extends EditorNodeContract implements OnInit {
protected savedValue = 'Click to edit...';
public contents = '';
private dirtyOverride = false;
private editorGroupSocket?: FlitterSocketConnection;
public editorGroupUsers: Array<{uuid: string, uid: string, display: string, color: string}> = [];
public editorGroupId?: string;
protected editingUserSelections: Array<EditingUserSelect> = [];
public requestSelectionRefresh = () => this.refreshRemoteSelections();
constructor(
public editorService: EditorService,
public readonly api: ApiService,
) {
super();
this.contents = this.initialValue;
this.savedValue = this.initialValue;
console.log('Norm editor component', this);
}
public isDark() {
@ -58,10 +72,92 @@ export class NormComponent extends EditorNodeContract implements OnInit {
});
}
ngOnDestroy() {
debug('ngOnDestroy in Norm editor component');
if ( this.editorGroupSocket && this.editorGroupId ) {
debug('Closing editor socket...');
this.editorGroupSocket.socket.close();
}
}
onContentsChanged(contents: string) {
if ( this.contents !== contents ) {
this.contents = contents;
this.editorService.triggerSave();
}
}
public needsLoad(): boolean | Promise<boolean> {
return true;
}
public async performLoad(): Promise<void> {
// This is called after the Node record has been loaded.
// FIXME need to find a consistent way of doing this on prod/development
// FIXME Probably make use of the systemBase, but allow overriding it in the environment
// const url = this.api._build_url('socket/norm-editor');
const url = 'ws://localhost:8000/api/v1/socket/norm-editor';
debug(`Norm editor socket URL: ${url}`);
const socket = new FlitterSocketConnection(url);
socket.controller(this);
await socket.on_open();
debug('Connected to norm editor socket', socket);
const [transaction2, socket2, { editor_group_id }] = await socket.asyncRequest('join_editor_group', {
NodeId: this.node.UUID,
PageId: this.page.UUID,
});
this.editorGroupSocket = socket;
const [transaction3, socket3, users = []] = await socket.asyncRequest('get_editor_group_users', { editor_group_id });
if ( Array.isArray(users) ) {
this.editorGroupUsers = users;
}
this.editorGroupId = editor_group_id;
await this.refreshRemoteSelections();
}
setEditorGroupUsers(transaction: FlitterSocketServerClientTransaction, socket: any) {
if ( Array.isArray(transaction?.incoming?.users) ) {
this.editorGroupUsers = transaction.incoming.users;
debug('Refreshed norm editor group users.');
transaction.resolve();
}
}
setEditorGroupSelections(transaction: FlitterSocketServerClientTransaction, socket: any) {
if ( Array.isArray(transaction?.incoming?.selections) ) {
debug('Got selections', transaction.incoming.selections);
this.editingUserSelections = transaction.incoming.selections;
transaction.resolve();
}
}
async onSelectionChanged(selection: { path: string, offset: number }) {
if ( this.editorGroupSocket && this.editorGroupId ) {
await this.editorGroupSocket.asyncRequest('set_member_selection', {
editor_group_id: this.editorGroupId,
selection,
});
}
}
async refreshRemoteSelections() {
if ( this.editorGroupSocket && this.editorGroupId ) {
const [
transaction, _, data
] = await this.editorGroupSocket.asyncRequest('get_selections', {
editor_group_id: this.editorGroupId,
});
if ( Array.isArray(data?.selections) ) {
this.editingUserSelections = data.selections;
}
}
}
}

View File

@ -1,5 +1,13 @@
<div [ngClass]="isDark() ? 'container dark' : 'container'"
(dblclick)="onFocusIn($event)">
<div class="toolbar-base" *ngIf="editingUsers && editingUsers.length">
<div class="editing-msg">Also editing:</div>
<div class="editing-user"
*ngFor="let user of editingUsers"
[ngStyle]="{background: user.color, color: getContrastYIQ(user.color.slice(1))}"
[title]="user.display"
>{{ user.display.charAt(0).toUpperCase() }}</div>
</div>
<div class="toolbar-base" *ngIf="isFocused">
<button class="toolbar-button" title="Bold" (click)="documentCommand('bold')">
<i class="icon fa fa-bold"></i>
@ -65,6 +73,14 @@
</button>
</div>
<div class="editable-container">
<div class="remote-cursors-container" *ngIf="editable && editingUserSelections && editingUserSelections.length">
<div class="remote-cursor"
*ngFor="let sel of editingUserSelections"
[ngStyle]="{backgroundColor: sel.user_data.color, left: (sel.left || 0) + 'px', top: (sel.top || 0) + 'px'}"
[title]="sel.user_data.display"
></div>
</div>
<div
class="editable-base"
[ngClass]="isFocused ? 'focused' : ''"
@ -75,6 +91,7 @@
#editable
(domChange)="onContentsChanged($event)"
></div>
</div>
<div
class="editable-base"
*ngIf="!isFocused"

View File

@ -14,6 +14,28 @@
flex-direction: row;
flex-wrap: wrap;
.editing-user {
width: 30px;
text-align: center;
margin: 3px;
border-radius: 3px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 1.3em;
font-weight: bold;
}
.editing-msg {
display: flex;
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px;
font-size: 0.9em;
}
.toolbar-button {
height: calc(100% - 6px);
min-width: 30px;
@ -38,6 +60,14 @@
}
}
.remote-cursors-container {
.remote-cursor {
min-height: 20px;
width: 3px;
position: absolute;
}
}
.container.dark {
.editable-base.focused {
background: #404040 !important;

View File

@ -1,4 +1,17 @@
import {Component, EventEmitter, HostListener, Input, OnInit, Output, ViewChild} from '@angular/core';
import {debug} from '../../utility';
import {DomSanitizer} from '@angular/platform-browser';
export interface EditingUserSelect {
path: string;
offset: string;
user_data: {
uuid: string,
uid: string,
display: string,
color: string
};
}
@Component({
selector: 'wysiwyg-editor',
@ -8,6 +21,7 @@ import {Component, EventEmitter, HostListener, Input, OnInit, Output, ViewChild}
export class WysiwygComponent implements OnInit {
@ViewChild('editable') editable;
@Input() readonly = false;
@Input() requestSelectionRefresh?: () => any;
@Input() set contents(val: string) {
if ( this.isFocused ) {
if ( this.editingContents !== val ) {
@ -24,7 +38,20 @@ export class WysiwygComponent implements OnInit {
return this.currentContents;
}
@Input() editingUsers: Array<{uuid: string, uid: string, display: string, color: string}> = [];
protected privEditingUserSelections: Array<any> = [];
@Input() set editingUserSelections(sels: Array<any>) {
this.privEditingUserSelections = sels;
this.processSelections();
}
get editingUserSelections() {
return this.privEditingUserSelections;
}
@Output() contentsChanged: EventEmitter<string> = new EventEmitter<string>();
@Output() selectionChanged: EventEmitter<{path: string, offset: number}> = new EventEmitter<{path: string; offset: number}>();
public currentContents = '';
protected editingContents = '';
@ -50,15 +77,109 @@ export class WysiwygComponent implements OnInit {
.replace(/(https?:\/\/[^\s]+)/g, (val) => `<a href="${val}" target="_blank">${val}</a>`);
}
constructor(
protected sanitizer: DomSanitizer,
) { }
public isDark() {
return document.body.classList.contains('dark');
}
ngOnInit() { }
ngOnInit() {
debug('Initializing WYSIWYG', this);
document.addEventListener('selectionchange', event => {
this.selectionChanged.emit(this.getSerializedSelection());
});
}
onFocusIn(event: MouseEvent) {
this.isFocused = !this.readonly;
this.editingContents = this.currentContents;
if ( this.requestSelectionRefresh ) {
this.requestSelectionRefresh();
}
}
getSerializedSelection() {
const selection = window.getSelection();
if ( this.editable && this.editable.nativeElement.contains(selection.focusNode) ) {
return {
path: this.getPathToElement(selection.focusNode),
offset: selection.focusOffset,
};
}
}
getElementFromPath(path: string) {
if ( !this.editable ) {
return;
}
const parts = path.split('.')
.map(x => x.split('#'))
.map(([tagName, idxString]) => [tagName, Number(idxString)]);
let currentElem = this.editable.nativeElement;
for ( const part of parts ) {
if ( !currentElem ) {
return;
}
const [tagName, idx] = part;
const children = [...currentElem.getElementsByTagName(tagName)];
currentElem = children[idx];
}
return currentElem;
}
getPathToElement(elem: any) {
if ( !this.editable ) {
throw new Error('Cannot get path to element unless editable.');
}
const maxNest = 5000;
let currentNest = 0;
const parents = [];
let currentParent = elem;
do {
currentNest += 1;
if ( currentNest > maxNest ) {
throw new Error('Reached max nesting limit.');
}
currentParent = currentParent.parentElement;
if ( currentParent ) {
parents.push(currentParent);
}
} while ( currentParent && currentParent !== this.editable.nativeElement );
return parents.reverse()
.slice(1)
.map((element, idx) => {
const siblings = element.parentElement.getElementsByTagName(element.tagName);
let siblingIdx = -1;
[...siblings].some((sibling, potentialIdx) => {
if ( sibling === element ) {
siblingIdx = potentialIdx;
return true;
}
});
return `${element.tagName}#${siblingIdx}`;
})
.join('.');
}
processSelections() {
for ( const sel of this.privEditingUserSelections ) {
sel.element = this.getElementFromPath(sel.path);
sel.top = sel.element.offsetTop;
sel.left = sel.element.offsetLeft;
console.log({sel, editable: this.editable});
}
}
@HostListener('document:keyup.escape', ['$event'])
@ -133,4 +254,13 @@ export class WysiwygComponent implements OnInit {
event.preventDefault();
this.documentCommand('redo');
}
getContrastYIQ(hexcolor) {
hexcolor = hexcolor.replace('#', '');
const r = parseInt(hexcolor.substr(0, 2), 16);
const g = parseInt(hexcolor.substr(2, 2), 16);
const b = parseInt(hexcolor.substr(4, 2), 16);
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
return (yiq >= 128) ? 'black' : 'white';
}
}

323
src/app/flitter-socket.ts Normal file
View File

@ -0,0 +1,323 @@
import {uuid_v4} from './utility';
const _FWS = WebSocket;
export class FlitterSocketTransaction {
connection: any;
type: string;
id: string;
resolved = false;
socket: any;
outgoing: any;
incoming: any;
connectionId: string;
sent = false;
received = false;
// tslint:disable-next-line:variable-name
_status = 200;
// tslint:disable-next-line:variable-name
_message = '';
endpoint = '';
json = '';
constructor(data, conn) {
this.connection = conn;
this.type = data.type;
this.id = data.transaction_id ? data.transaction_id : conn.uuid();
this.socket = conn.socket;
this.outgoing = data.outgoing ? data.outgoing : {};
this.incoming = data.incoming ? data.incoming : {};
this.connectionId = conn.id;
}
resolve() {
this.resolved = true;
}
status(code: number = null) {
if ( code ) {
this._status = code;
return this;
}
return this._status;
}
message(msg: string = null) {
if ( msg ) {
this._message = msg;
return this;
}
return this._message;
}
send(data: any = null) {
if ( this.type === 'response' ) {
if ( this.resolved ) { throw new Error(`Transaction can only be sent once per request. (ID: ${this.id})`); }
const obj = {
status: this._status,
transaction_id: this.id,
type: 'response',
... (this._message && {message: this._message}),
... (data && {data}),
... ((!data && this.outgoing) && {data: this.outgoing})
};
this.json = JSON.stringify(obj);
this.resolve();
} else if ( this.type === 'request' ) {
if ( this.sent ) { throw new Error(`Request can only be sent once per Transaction. (ID: ${this.id})`); }
const obj = {
endpoint: this.endpoint,
transaction_id: this.id,
type: 'request',
... (data && {data}),
... ((!data && this.outgoing) && {data: this.outgoing})
};
this.json = JSON.stringify(obj);
}
this.sent = true;
console.log('Sending message...', this.json);
return this.socket.send(this.json);
}
}
export class FlitterSocketClientServerTransaction extends FlitterSocketTransaction {
// tslint:disable-next-line:variable-name
_handler: any;
constructor(data, conn) {
if ( data.data ) { data.outgoing = data.data; }
super(data, conn);
this.type = 'request';
if ( data.endpoint ) { this.endpoint = data.endpoint; }
}
handler(fn) {
if ( !fn ) { return this._handler; }
this._handler = fn;
return this;
}
receipt(data) {
this.received = true;
return this._handler(this, this.socket, data);
}
}
export class FlitterSocketServerClientTransaction extends FlitterSocketTransaction {
constructor(data, conn) {
if ( data.data ) { data.incoming = data.data; }
super(data, conn);
this.type = 'response';
if ( data.endpoint ) { this.endpoint = data.endpoint; }
}
set(key, value = null) {
if ( value && typeof this.outgoing === 'object' ) { this.outgoing[key] = value; } else { this.outgoing = key; }
return this;
}
}
export class FlitterSocketConnection {
url: string;
open = false;
activeTransactions: any = {};
id: string;
// tslint:disable-next-line:variable-name
_controller: any = {};
closeResolves: any[] = [];
closeCallbacks: any[] = [];
openResolves: any[] = [];
openCallbacks: any[] = [];
socket: any;
constructor(url) {
this.url = url;
this.open = false;
this.activeTransactions = {};
this.id = this.uuid();
this._controller = {};
this._create_socket();
this.closeResolves = [];
this.closeCallbacks = [];
this.openResolves = [];
this.openCallbacks = [];
}
on_close(callback = false) {
if ( !callback ) {
return new Promise(resolve => {
this.closeResolves.push(resolve);
});
} else {
this.closeCallbacks.push(callback);
}
}
on_open(callback = false) {
if ( !callback ) {
return new Promise(resolve => {
this.openResolves.push(resolve);
});
} else {
this.openCallbacks.push(callback);
}
}
_create_socket() {
console.log('Connecting to socket:', this.url);
this.socket = new _FWS(this.url);
this.socket.onopen = (e) => {
this.open = true;
this.openResolves.forEach(r => r(this));
this.openCallbacks.forEach(c => c(this));
this.openResolves = [];
this.openCallbacks = [];
};
this.socket.onmessage = this._on_message.bind(this);
this.socket.onclose = (e) => {
this.open = false;
this.closeResolves.forEach(r => r(this));
this.closeCallbacks.forEach(c => c(this));
this.closeResolves = [];
this.closeCallbacks = [];
};
}
_on_message(event) {
const data = this.validate_message(event.data);
if ( !data ) { return; }
if ( data.type === 'response' ) {
if ( Object.keys(this.activeTransactions).includes(data.transaction_id) ) {
const t = this.activeTransactions[data.transaction_id];
t.receipt(data.data);
if ( t.resolved ) { delete this.activeTransactions[t.id]; }
} else { throw new Error('Response: transaction ID not found. It\'s possible that the transaction was already resolved.'); }
} else if ( data.type === 'request' ) {
const t = new FlitterSocketServerClientTransaction(data, this);
this.activeTransactions[t.id] = t;
this._controller[t.endpoint](t, this.socket);
if ( t.resolved ) { delete this.activeTransactions[t.id]; }
}
}
request(endpoint, data, handler) {
const t = new FlitterSocketClientServerTransaction({data, endpoint}, this);
t.handler(handler);
this.activeTransactions[t.id] = t;
t.send();
}
async asyncRequest(endpoint, data): Promise<[any, any, any]> {
return new Promise((res, rej) => {
try {
this.request(endpoint, data, (...args: any) => {
res(args);
});
} catch (e: unknown) {
rej(e);
}
});
}
validate_message(msg) {
let fail = false;
let validTid = true;
let error = '';
let code = 400;
// check if valid JSON
if ( !this.is_json(msg) ) {
fail = true;
error = 'Incoming message must be valid FSP JSON object.';
validTid = false;
}
let data;
if ( !fail ) { data = JSON.parse(msg); }
// check for required fields: transaction_id, type
if ( !fail && !Object.keys(data).includes('transaction_id') ) {
fail = true;
error = 'Incoming message must include universally-unique transaction_id.';
validTid = false;
}
if ( !fail && (!Object.keys(data).includes('type') || !(['request', 'response'].includes(data.type))) ) {
fail = true;
error = 'Incoming message must include valid type, which may be one of: request, response.';
}
// if request, check for required fields: endpoint
if ( !fail && data.type === 'request' && !Object.keys(data).includes('endpoint') ) {
fail = true;
error = 'Incoming request message must include a valid endpoint.';
}
// if request, check if transaction_id is unique
if ( !fail && data.type === 'request' && Object.keys(this.activeTransactions).includes(data.transaction_id) ) {
fail = true;
error = 'Incoming request message must have a universally-unique, NON-EXISTENT transaction_id.';
validTid = false;
}
// if request, check for valid endpoint
if ( !fail && data.type === 'request' && !(typeof this._controller[data.endpoint] === 'function') ) {
fail = true;
error = 'The requested endpoint does not exist or is invalid.';
code = 404;
}
// if response, check if transaction_id exists
if ( !fail && data.type === 'response' && !Object.keys(this.activeTransactions).includes(data.transaction_id)) {
fail = true;
error = 'The specified transaction_id does not exist. It\'s possible that this transaction has already resolved.';
}
if ( fail ) {
const sendData = {
transaction_id: validTid ? data.transaction_id : 'unknown',
message: error,
status: code,
type: 'response'
};
this.send_raw(sendData);
} else { return data; }
}
send_raw(data) {
this.socket.send(JSON.stringify(data));
}
uuid() {
return uuid_v4();
}
controller(set: any = false) {
if ( !set ) { return this._controller; }
this._controller = set;
return this;
}
is_json(str) {
try {
JSON.parse(str);
return true;
} catch (e) {
return false;
}
}
}