You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
frontend/src/app/components/wysiwyg/wysiwyg.component.ts

382 lines
12 KiB

import {Component, ElementRef, 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
};
}
export interface MutationBroadcast {
path: string;
type: 'characterData' | 'attributes' | 'childList';
data: string;
fullContents: string;
addedNodes?: Array<{previousSiblingPath: string, type: string, data: string}>;
}
@Component({
selector: 'wysiwyg-editor',
templateUrl: './wysiwyg.component.html',
styleUrls: ['./wysiwyg.component.scss'],
})
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 ) {
this.editingContents = val;
}
} else {
if ( this.currentContents !== val ) {
this.currentContents = val;
}
}
}
get contents(): string {
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}>();
@Output() contentsMutated: EventEmitter<MutationBroadcast> = new EventEmitter<MutationBroadcast>();
public currentContents = '';
protected editingContents = '';
public isFocused = false;
protected hadOneFocusOut = false;
protected isEditOnly = false;
protected applyingRemoteMutation = false;
protected ignoreNextMutation = false;
public get editonly() {
return this.isEditOnly;
}
@Input()
public set editonly(val: boolean) {
this.isEditOnly = val;
if ( this.isEditOnly && !this.readonly ) {
this.isFocused = true;
}
}
public get displayContents() {
return (this.contents || 'Double-click to edit...').replace(/</g, '\n<')
.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() {
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 = [...(tagName === '#text' ? currentElem.childNodes : currentElem.getElementsByTagName(tagName))];
currentElem = children[idx];
}
return currentElem;
}
getPathToElement(elem: any) {
if ( !this.editable ) {
throw new Error('Cannot get path to element unless editable.');
}
debug('getPathToElement', elem);
const maxNest = 5000;
let currentNest = 0;
const parents = [elem];
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.tagName ? element.parentElement.getElementsByTagName(element.tagName)
: element.parentElement.childNodes;
let siblingIdx = -1;
[...siblings].some((sibling, potentialIdx) => {
if ( sibling === element ) {
siblingIdx = potentialIdx;
return true;
}
});
return `${element.tagName || '#text'}@${siblingIdx}`;
})
.join('.');
}
processSelections() {
if ( !this.editable ) {
return;
}
const editableBase = this.editable.nativeElement;
for ( const sel of this.privEditingUserSelections ) {
if ( !sel.path ) {
continue;
}
sel.element = this.getElementFromPath(sel.path);
if ( sel.element ) {
sel.top = sel.element.offsetTop;
sel.left = sel.element.offsetLeft;
const range = document.createRange();
range.collapse(true);
range.setStart(sel.element, sel.offset);
range.setEnd(sel.element, sel.offset);
const rect = range.getBoundingClientRect();
sel.top = rect.top;
// FIXME I'm not sure why the 45px offset is needed, but it is...
sel.left = (rect.left - editableBase.getBoundingClientRect().left) + 45;
}
}
}
@HostListener('document:keyup.escape', ['$event'])
onFocusOut(event) {
if ( this.isEditOnly || !this.isFocused ) {
return;
}
if ( !this.hadOneFocusOut ) {
this.hadOneFocusOut = true;
setTimeout(() => {
this.hadOneFocusOut = false;
}, 500);
} else {
this.isFocused = false;
this.hadOneFocusOut = false;
this.currentContents = this.editingContents;
}
}
documentCommand(cmd: string) {
// Yes, technically this is deprecated, but it'll be poly-filled if necessary. Bite me.
document.execCommand(cmd, false, '');
}
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});
const innerHTML = this.editable.nativeElement.innerHTML;
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,
});
}
if ( this.contents !== innerHTML ) {
this.contents = innerHTML;
this.contentsChanged.emit(innerHTML);
}
}
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;
}
@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');
}
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';
}
}