Start adding real-time collab support to WYSIWYG
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user