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.
282 lines
8.3 KiB
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';
|
|
}
|
|
}
|