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

This commit is contained in:
2021-01-02 15:11:42 -06:00
parent 8f7ff1de73
commit c7f9a59cc4
8 changed files with 629 additions and 13 deletions

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,16 +73,25 @@
</button>
</div>
<div
class="editable-base"
[ngClass]="isFocused ? 'focused' : ''"
contenteditable
appDomChange
*ngIf="isFocused"
[innerHTML]="contents"
#editable
(domChange)="onContentsChanged($event)"
></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' : ''"
contenteditable
appDomChange
*ngIf="isFocused"
[innerHTML]="contents"
#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';
}
}