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 = []; @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}>(); @Output() contentsMutated: EventEmitter = new EventEmitter(); 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(/ `${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() { 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'; } }