diff --git a/capacitor.config.json b/capacitor.config.json new file mode 100644 index 0000000..45bcc4b --- /dev/null +++ b/capacitor.config.json @@ -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" +} diff --git a/proxy.conf.json b/proxy.conf.json index 1e7fe81..3857d23 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -2,6 +2,8 @@ "/link_api": { "target": "http://localhost:8000", "secure": false, + "ws": true, + "changeOrigin": true, "logLevel": "debug", "pathRewrite": {"^/link_api": ""} } diff --git a/src/app/components/nodes/norm/norm.component.html b/src/app/components/nodes/norm/norm.component.html index 53fdfd8..eee556d 100644 --- a/src/app/components/nodes/norm/norm.component.html +++ b/src/app/components/nodes/norm/norm.component.html @@ -2,6 +2,10 @@ \ No newline at end of file diff --git a/src/app/components/nodes/norm/norm.component.ts b/src/app/components/nodes/norm/norm.component.ts index 955cced..2dcfad1 100644 --- a/src/app/components/nodes/norm/norm.component.ts +++ b/src/app/components/nodes/norm/norm.component.ts @@ -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 = []; + + 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 { + return true; + } + + public async performLoad(): Promise { + // 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; + } + } + } } diff --git a/src/app/components/wysiwyg/wysiwyg.component.html b/src/app/components/wysiwyg/wysiwyg.component.html index 903c5aa..01680f7 100644 --- a/src/app/components/wysiwyg/wysiwyg.component.html +++ b/src/app/components/wysiwyg/wysiwyg.component.html @@ -1,5 +1,13 @@
+
+
Also editing:
+
{{ user.display.charAt(0).toUpperCase() }}
+
-
+
+
+
+
+
+
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 = []; + @Input() set editingUserSelections(sels: Array) { + this.privEditingUserSelections = sels; + this.processSelections(); + } + + get editingUserSelections() { + return this.privEditingUserSelections; + } + @Output() contentsChanged: EventEmitter = new EventEmitter(); + @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) => `${val}`); } + 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'; + } } diff --git a/src/app/flitter-socket.ts b/src/app/flitter-socket.ts new file mode 100644 index 0000000..31532d8 --- /dev/null +++ b/src/app/flitter-socket.ts @@ -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; + } + } +}