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;
+ }
+ }
+}