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

282 lines
8.3 KiB

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',
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}>();
public currentContents = '';
protected editingContents = '';
public isFocused = false;
protected hadOneFocusOut = false;
protected isEditOnly = 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() {
for ( const sel of this.privEditingUserSelections ) {
sel.element = this.getElementFromPath(sel.path);
if ( sel.element ) {
sel.top = sel.element.offsetTop;
sel.left = sel.element.offsetLeft;
const range = document.createRange();
range.setStart(sel.element, sel.offset);
range.setEnd(sel.element, sel.offset);
const rect = range.getClientRects()[0];
sel.top = rect.top;
sel.left = rect.left;
}
console.log({sel, editable: this.editable});
}
}
@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(contents: string) {
const innerHTML = this.editable.nativeElement.innerHTML;
if ( this.contents !== innerHTML ) {
this.contents = innerHTML;
this.contentsChanged.emit(innerHTML);
}
}
@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';
}
}