2021-01-31 16:26:07 +00:00
|
|
|
import {Component, ElementRef, EventEmitter, HostListener, Input, OnInit, Output, ViewChild} from '@angular/core';
|
2021-01-02 21:11:42 +00:00
|
|
|
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
|
|
|
|
};
|
|
|
|
}
|
2020-11-10 14:20:22 +00:00
|
|
|
|
2021-01-31 16:26:07 +00:00
|
|
|
export interface MutationBroadcast {
|
|
|
|
path: string;
|
|
|
|
type: 'characterData' | 'attributes' | 'childList';
|
|
|
|
data: string;
|
|
|
|
fullContents: string;
|
|
|
|
addedNodes?: Array<{previousSiblingPath: string, type: string, data: string}>;
|
|
|
|
}
|
|
|
|
|
2020-11-10 14:20:22 +00:00
|
|
|
@Component({
|
|
|
|
selector: 'wysiwyg-editor',
|
|
|
|
templateUrl: './wysiwyg.component.html',
|
|
|
|
styleUrls: ['./wysiwyg.component.scss'],
|
|
|
|
})
|
|
|
|
export class WysiwygComponent implements OnInit {
|
|
|
|
@ViewChild('editable') editable;
|
|
|
|
@Input() readonly = false;
|
2021-01-02 21:11:42 +00:00
|
|
|
@Input() requestSelectionRefresh?: () => any;
|
2020-11-13 03:16:18 +00:00
|
|
|
@Input() set contents(val: string) {
|
2020-11-16 03:00:29 +00:00
|
|
|
if ( this.isFocused ) {
|
|
|
|
if ( this.editingContents !== val ) {
|
|
|
|
this.editingContents = val;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if ( this.currentContents !== val ) {
|
|
|
|
this.currentContents = val;
|
|
|
|
}
|
2020-11-13 03:16:18 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
get contents(): string {
|
|
|
|
return this.currentContents;
|
|
|
|
}
|
|
|
|
|
2021-01-02 21:11:42 +00:00
|
|
|
@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;
|
|
|
|
}
|
|
|
|
|
2020-11-10 14:20:22 +00:00
|
|
|
@Output() contentsChanged: EventEmitter<string> = new EventEmitter<string>();
|
2021-01-02 21:11:42 +00:00
|
|
|
@Output() selectionChanged: EventEmitter<{path: string, offset: number}> = new EventEmitter<{path: string; offset: number}>();
|
2021-01-31 16:26:07 +00:00
|
|
|
@Output() contentsMutated: EventEmitter<MutationBroadcast> = new EventEmitter<MutationBroadcast>();
|
2020-11-10 14:20:22 +00:00
|
|
|
|
2020-11-13 03:16:18 +00:00
|
|
|
public currentContents = '';
|
2020-11-16 03:00:29 +00:00
|
|
|
protected editingContents = '';
|
2020-11-13 03:16:18 +00:00
|
|
|
|
2020-11-10 14:20:22 +00:00
|
|
|
public isFocused = false;
|
|
|
|
protected hadOneFocusOut = false;
|
2020-11-10 14:35:29 +00:00
|
|
|
protected isEditOnly = false;
|
2021-01-31 16:26:07 +00:00
|
|
|
protected applyingRemoteMutation = false;
|
|
|
|
protected ignoreNextMutation = false;
|
2020-11-10 14:35:29 +00:00
|
|
|
|
|
|
|
public get editonly() {
|
|
|
|
return this.isEditOnly;
|
|
|
|
}
|
|
|
|
|
|
|
|
@Input()
|
|
|
|
public set editonly(val: boolean) {
|
|
|
|
this.isEditOnly = val;
|
|
|
|
if ( this.isEditOnly && !this.readonly ) {
|
|
|
|
this.isFocused = true;
|
|
|
|
}
|
|
|
|
}
|
2020-11-10 14:20:22 +00:00
|
|
|
|
|
|
|
public get displayContents() {
|
2021-02-02 15:11:59 +00:00
|
|
|
return this.replaceLinks((this.contents || 'Double-click to edit...').replace(/</g, '\n<'));
|
|
|
|
}
|
|
|
|
|
|
|
|
private replaceLinks(text) {
|
|
|
|
const exp = /((href|src)=["']|)(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig;
|
|
|
|
return text.replace(exp, (...args) => {
|
|
|
|
return args[2] ?
|
|
|
|
args[0] :
|
|
|
|
'<a href="' + args[3] + '" target="_blank">' + args[3] + '</a>';
|
|
|
|
});
|
2020-11-10 14:20:22 +00:00
|
|
|
}
|
|
|
|
|
2021-01-02 21:11:42 +00:00
|
|
|
constructor(
|
|
|
|
protected sanitizer: DomSanitizer,
|
|
|
|
) { }
|
|
|
|
|
2020-11-10 14:20:22 +00:00
|
|
|
public isDark() {
|
|
|
|
return document.body.classList.contains('dark');
|
|
|
|
}
|
|
|
|
|
2021-01-02 21:11:42 +00:00
|
|
|
ngOnInit() {
|
|
|
|
debug('Initializing WYSIWYG', this);
|
|
|
|
|
|
|
|
document.addEventListener('selectionchange', event => {
|
|
|
|
this.selectionChanged.emit(this.getSerializedSelection());
|
|
|
|
});
|
|
|
|
}
|
2020-11-10 14:20:22 +00:00
|
|
|
|
|
|
|
onFocusIn(event: MouseEvent) {
|
|
|
|
this.isFocused = !this.readonly;
|
2020-11-16 03:00:29 +00:00
|
|
|
this.editingContents = this.currentContents;
|
2021-01-02 21:11:42 +00:00
|
|
|
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('.')
|
2021-01-03 03:55:58 +00:00
|
|
|
.map(x => x.split('@'))
|
2021-01-02 21:11:42 +00:00
|
|
|
.map(([tagName, idxString]) => [tagName, Number(idxString)]);
|
|
|
|
|
|
|
|
let currentElem = this.editable.nativeElement;
|
|
|
|
for ( const part of parts ) {
|
|
|
|
if ( !currentElem ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const [tagName, idx] = part;
|
2021-01-03 03:55:58 +00:00
|
|
|
const children = [...(tagName === '#text' ? currentElem.childNodes : currentElem.getElementsByTagName(tagName))];
|
2021-01-02 21:11:42 +00:00
|
|
|
currentElem = children[idx];
|
|
|
|
}
|
|
|
|
|
|
|
|
return currentElem;
|
|
|
|
}
|
|
|
|
|
|
|
|
getPathToElement(elem: any) {
|
|
|
|
if ( !this.editable ) {
|
|
|
|
throw new Error('Cannot get path to element unless editable.');
|
|
|
|
}
|
|
|
|
|
2021-01-03 03:55:58 +00:00
|
|
|
debug('getPathToElement', elem);
|
|
|
|
|
2021-01-02 21:11:42 +00:00
|
|
|
const maxNest = 5000;
|
|
|
|
let currentNest = 0;
|
2021-01-03 03:55:58 +00:00
|
|
|
const parents = [elem];
|
2021-01-02 21:11:42 +00:00
|
|
|
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) => {
|
2021-01-03 03:55:58 +00:00
|
|
|
const siblings = element.tagName ? element.parentElement.getElementsByTagName(element.tagName)
|
|
|
|
: element.parentElement.childNodes;
|
|
|
|
|
2021-01-02 21:11:42 +00:00
|
|
|
let siblingIdx = -1;
|
|
|
|
[...siblings].some((sibling, potentialIdx) => {
|
|
|
|
if ( sibling === element ) {
|
|
|
|
siblingIdx = potentialIdx;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2021-01-03 03:55:58 +00:00
|
|
|
return `${element.tagName || '#text'}@${siblingIdx}`;
|
2021-01-02 21:11:42 +00:00
|
|
|
})
|
|
|
|
.join('.');
|
|
|
|
}
|
|
|
|
|
|
|
|
processSelections() {
|
2021-01-31 16:26:07 +00:00
|
|
|
if ( !this.editable ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const editableBase = this.editable.nativeElement;
|
|
|
|
|
2021-01-02 21:11:42 +00:00
|
|
|
for ( const sel of this.privEditingUserSelections ) {
|
2021-01-31 16:26:07 +00:00
|
|
|
if ( !sel.path ) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2021-01-02 21:11:42 +00:00
|
|
|
sel.element = this.getElementFromPath(sel.path);
|
2021-01-03 03:55:58 +00:00
|
|
|
if ( sel.element ) {
|
|
|
|
sel.top = sel.element.offsetTop;
|
|
|
|
sel.left = sel.element.offsetLeft;
|
|
|
|
|
|
|
|
const range = document.createRange();
|
2021-01-31 16:26:07 +00:00
|
|
|
range.collapse(true);
|
2021-01-03 03:55:58 +00:00
|
|
|
range.setStart(sel.element, sel.offset);
|
|
|
|
range.setEnd(sel.element, sel.offset);
|
|
|
|
|
2021-01-31 16:26:07 +00:00
|
|
|
const rect = range.getBoundingClientRect();
|
|
|
|
|
2021-01-03 03:55:58 +00:00
|
|
|
sel.top = rect.top;
|
|
|
|
|
2021-01-31 16:26:07 +00:00
|
|
|
// FIXME I'm not sure why the 45px offset is needed, but it is...
|
|
|
|
sel.left = (rect.left - editableBase.getBoundingClientRect().left) + 45;
|
|
|
|
}
|
2021-01-02 21:11:42 +00:00
|
|
|
}
|
2020-11-10 14:20:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
@HostListener('document:keyup.escape', ['$event'])
|
|
|
|
onFocusOut(event) {
|
2020-11-16 15:37:45 +00:00
|
|
|
if ( this.isEditOnly || !this.isFocused ) {
|
2020-11-10 14:35:29 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-11-10 14:20:22 +00:00
|
|
|
if ( !this.hadOneFocusOut ) {
|
|
|
|
this.hadOneFocusOut = true;
|
|
|
|
setTimeout(() => {
|
|
|
|
this.hadOneFocusOut = false;
|
|
|
|
}, 500);
|
|
|
|
} else {
|
|
|
|
this.isFocused = false;
|
|
|
|
this.hadOneFocusOut = false;
|
2020-11-16 03:00:29 +00:00
|
|
|
this.currentContents = this.editingContents;
|
2020-11-10 14:20:22 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
documentCommand(cmd: string) {
|
|
|
|
// Yes, technically this is deprecated, but it'll be poly-filled if necessary. Bite me.
|
|
|
|
document.execCommand(cmd, false, '');
|
|
|
|
}
|
|
|
|
|
2021-01-31 16:26:07 +00:00
|
|
|
onContentsChanged(mutation: MutationRecord) {
|
|
|
|
if ( this.applyingRemoteMutation ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if ( this.ignoreNextMutation ) {
|
|
|
|
this.ignoreNextMutation = false;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const target = this.getPathToElement(mutation.target);
|
|
|
|
console.log({mutation, target, type: mutation.type, data: mutation.target.textContent});
|
|
|
|
|
2020-11-10 14:20:22 +00:00
|
|
|
const innerHTML = this.editable.nativeElement.innerHTML;
|
2021-01-31 16:26:07 +00:00
|
|
|
|
|
|
|
if ( mutation.type === 'characterData' ) {
|
|
|
|
this.contentsMutated.emit({
|
|
|
|
path: target,
|
|
|
|
type: mutation.type,
|
|
|
|
data: mutation.target.textContent,
|
|
|
|
fullContents: innerHTML,
|
|
|
|
});
|
|
|
|
} else if ( mutation.type === 'childList' ) {
|
|
|
|
const addedNodes: Array<{previousSiblingPath: string, type: string, data: string}> = [];
|
|
|
|
|
|
|
|
mutation.addedNodes.forEach(value => {
|
|
|
|
const previousSiblingPath = this.getPathToElement(value.previousSibling);
|
|
|
|
if ( previousSiblingPath ) {
|
|
|
|
addedNodes.push({
|
|
|
|
previousSiblingPath,
|
|
|
|
type: value.nodeName,
|
|
|
|
data: value.textContent,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
this.contentsMutated.emit({
|
|
|
|
path: target,
|
|
|
|
type: mutation.type,
|
|
|
|
data: mutation.target.textContent,
|
|
|
|
fullContents: innerHTML,
|
|
|
|
addedNodes,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-11-10 14:20:22 +00:00
|
|
|
if ( this.contents !== innerHTML ) {
|
|
|
|
this.contents = innerHTML;
|
|
|
|
this.contentsChanged.emit(innerHTML);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-01-31 16:26:07 +00:00
|
|
|
applyRemoteContentMutation(mutation: MutationBroadcast) {
|
|
|
|
this.applyingRemoteMutation = true;
|
|
|
|
console.log('got remote content mutation', mutation);
|
|
|
|
|
|
|
|
if ( this.editable ) {
|
|
|
|
const target = this.getElementFromPath(mutation.path);
|
|
|
|
console.log(target);
|
|
|
|
if ( target ) {
|
|
|
|
if ( mutation.type === 'characterData' ) {
|
|
|
|
this.ignoreNextMutation = true;
|
|
|
|
target.nodeValue = mutation.data;
|
|
|
|
} else if ( mutation.type === 'childList' ) {
|
|
|
|
if ( Array.isArray(mutation.addedNodes) ) {
|
|
|
|
for ( const addedNode of mutation.addedNodes ) {
|
|
|
|
const previousSibling = this.getElementFromPath(addedNode.previousSiblingPath);
|
|
|
|
const elem = document.createElement(addedNode.type);
|
|
|
|
elem.nodeValue = addedNode.data;
|
|
|
|
|
|
|
|
this.ignoreNextMutation = true;
|
|
|
|
previousSibling.parentElement.insertBefore(elem, previousSibling.nextSibling);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if ( this.contents !== mutation.fullContents ) {
|
|
|
|
this.ignoreNextMutation = true;
|
|
|
|
this.contents = mutation.fullContents;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.applyingRemoteMutation = false;
|
|
|
|
}
|
|
|
|
|
2020-11-10 14:20:22 +00:00
|
|
|
@HostListener('document:keydown.tab', ['$event'])
|
|
|
|
onIndent(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
this.documentCommand('indent');
|
|
|
|
}
|
|
|
|
|
|
|
|
@HostListener('document:keydown.shift.tab', ['$event'])
|
|
|
|
onOutdent(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
this.documentCommand('outdent');
|
|
|
|
}
|
|
|
|
|
|
|
|
@HostListener('document:keydown.control.b', ['$event'])
|
|
|
|
onBold(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
this.documentCommand('bold');
|
|
|
|
}
|
|
|
|
|
|
|
|
@HostListener('document:keydown.control.i', ['$event'])
|
|
|
|
onItalic(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
this.documentCommand('italic');
|
|
|
|
}
|
|
|
|
|
|
|
|
@HostListener('document:keydown.control.u', ['$event'])
|
|
|
|
onUnderline(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
this.documentCommand('underline');
|
|
|
|
}
|
|
|
|
|
|
|
|
@HostListener('document:keydown.control.z', ['$event'])
|
|
|
|
onUndo(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
this.documentCommand('undo');
|
|
|
|
}
|
|
|
|
|
|
|
|
@HostListener('document:keydown.control.shift.z', ['$event'])
|
|
|
|
onRedo(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
this.documentCommand('redo');
|
|
|
|
}
|
2021-01-02 21:11:42 +00:00
|
|
|
|
|
|
|
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';
|
|
|
|
}
|
2020-11-10 14:20:22 +00:00
|
|
|
}
|