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 = []; @Input() set editingUserSelections(sels: Array) { this.privEditingUserSelections = sels; this.processSelections(); } get editingUserSelections() { return this.privEditingUserSelections; } @Output() contentsChanged: EventEmitter = new EventEmitter(); @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(/ `${val}`); } 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'; } }