interface Datum { id: string; value: string; } export class Mention { static Keys = { TAB: 9, ENTER: 13, ESCAPE: 27, UP: 38, DOWN: 40, }; static numberIsNaN = (x: any) => x !== x; private isOpen = false; /** * index of currently selected item. */ private itemIndex = 0; private mentionCharPos: number | undefined = undefined; private cursorPos: number | undefined = undefined; private values = [] as Datum[]; private suspendMouseEnter = false; private options = { source: (searchTerm: string, renderList: Function, mentionChar: string) => {}, renderItem: (item: Datum, searchTerm: string) => { return `${item.value}`; }, onSelect: (item: DOMStringMap, insertItem: (item: DOMStringMap) => void) => { insertItem(item); }, mentionDenotationChars: ['@'], showDenotationChar: true, allowedChars: /^[a-zA-Z0-9_]*$/, minChars: 0, maxChars: 31, offsetTop: 2, offsetLeft: 0, /** * Whether or not the denotation character(s) should be isolated. For example, to avoid mentioning in an email. */ isolateCharacter: false, fixMentionsToQuill: false, defaultMenuOrientation: 'bottom', dataAttributes: ['id', 'value', 'denotationChar', 'link', 'target'], linkTarget: '_blank', onOpen: () => true, onClose: () => true, // Style options listItemClass: 'ql-mention-list-item', mentionContainerClass: 'ql-mention-list-container', mentionListClass: 'ql-mention-list', }; /// HTML elements private mentionContainer = document.createElement('div'); private mentionList = document.createElement('ul'); constructor( private quill: Quill, ) { this.mentionContainer.className = this.options.mentionContainerClass; this.mentionContainer.style.cssText = 'display: none; position: absolute;'; this.mentionContainer.onmousemove = this.onContainerMouseMove.bind(this); if (this.options.fixMentionsToQuill) { this.mentionContainer.style.width = 'auto'; } this.mentionList.className = this.options.mentionListClass; this.mentionContainer.appendChild(this.mentionList); this.quill.container.appendChild(this.mentionContainer); quill.on('text-change', this.onTextChange.bind(this)); quill.on('selection-change', this.onSelectionChange.bind(this)); quill.keyboard.addBinding({ key: Mention.Keys.ENTER, }, this.selectHandler.bind(this)); quill.keyboard.bindings[Mention.Keys.ENTER].unshift( quill.keyboard.bindings[Mention.Keys.ENTER].pop() ); /// ^^ place it at beginning of bindings. quill.keyboard.addBinding({ key: Mention.Keys.ESCAPE, }, this.escapeHandler.bind(this)); quill.keyboard.addBinding({ key: Mention.Keys.UP, }, this.upHandler.bind(this)); quill.keyboard.addBinding({ key: Mention.Keys.DOWN, }, this.downHandler.bind(this)); document.addEventListener("keypress", e => { /// Quick’n’dirty hack. if (! this.quill.hasFocus()) { return ; } setTimeout(() => { this.setCursorPos(); this.quill.removeFormat(this.cursorPos! - 1, 1, 'silent'); }, 0); }); } selectHandler() { if (this.isOpen) { this.selectItem(); return false; } return true; } escapeHandler() { if (this.isOpen) { this.hideMentionList(); return false; } return true; } upHandler() { if (this.isOpen) { this.prevItem(); return false; } return true; } downHandler() { if (this.isOpen) { this.nextItem(); return false; } return true; } showMentionList() { this.mentionContainer.style.visibility = 'hidden'; this.mentionContainer.style.display = ''; this.setMentionContainerPosition(); this.setIsOpen(true); } hideMentionList() { this.mentionContainer.style.display = 'none'; this.setIsOpen(false); } private highlightItem(scrollItemInView = true) { const childNodes = Array.from(this.mentionList.childNodes) as HTMLLIElement[]; for (const node of childNodes) { node.classList.remove('selected'); } childNodes[this.itemIndex].classList.add('selected'); if (scrollItemInView) { const itemHeight = childNodes[this.itemIndex].offsetHeight; const itemPos = this.itemIndex * itemHeight; const containerTop = this.mentionContainer.scrollTop; const containerBottom = containerTop + this.mentionContainer.offsetHeight; if (itemPos < containerTop) { // Scroll up if the item is above the top of the container this.mentionContainer.scrollTop = itemPos; } else if (itemPos > (containerBottom - itemHeight)) { // scroll down if any part of the element is below the bottom of the container this.mentionContainer.scrollTop += (itemPos - containerBottom) + itemHeight; } } } private getItemData(): DOMStringMap { const node = this.mentionList.childNodes[this.itemIndex] as HTMLElement; const { link } = node.dataset; const itemTarget = node.dataset.target; if (link !== undefined) { node.dataset.value = `${node.dataset.value}`; } return node.dataset; } onContainerMouseMove() { this.suspendMouseEnter = false; } selectItem() { const data = this.getItemData(); this.options.onSelect(data, (asyncData) => { this.insertItem(asyncData); }); this.hideMentionList(); } insertItem(data: DOMStringMap) { const render = data; if (render === null) { return ; } if (!this.options.showDenotationChar) { render.denotationChar = ''; } if (this.cursorPos === undefined) { throw new Error(`Invalid this.cursorPos`); } if (!render.value) { throw new Error(`Didn't receive value from server.`); } this.quill.insertText(this.cursorPos, render.value, 'bold', Quill.sources.USER); this.quill.setSelection(this.cursorPos + render.value.length, 0); this.setCursorPos(); this.hideMentionList(); } onItemMouseEnter(e: MouseEvent) { if (this.suspendMouseEnter) { return ; } const index = Number( (e.target as HTMLLIElement).dataset.index ); if (! Mention.numberIsNaN(index) && index !== this.itemIndex) { this.itemIndex = index; this.highlightItem(false); } } onItemClick(e: MouseEvent) { e.stopImmediatePropagation(); e.preventDefault(); this.itemIndex = Number( (e.currentTarget as HTMLElement).dataset.index ); this.highlightItem(); this.selectItem(); } private attachDataValues(element: HTMLLIElement, data: Datum): HTMLLIElement { for (const [key, value] of Object.entries(data)) { if (this.options.dataAttributes.includes(key)) { element.dataset[key] = value; } else { delete element.dataset[key]; } } return element; } renderList(mentionChar: string, data: Datum[], searchTerm: string = "") { if (data.length > 0) { this.values = data; this.mentionList.innerHTML = ''; for (const [i, datum] of data.entries()) { const li = document.createElement('li'); li.className = this.options.listItemClass; li.dataset.index = `${i}`; // li.innerHTML = this.options.renderItem(datum, searchTerm); li.innerText = datum.value.replace(/\n/g, "↵"); /// ^^ li.onmouseenter = this.onItemMouseEnter.bind(this); li.dataset.denotationChar = mentionChar; li.onclick = this.onItemClick.bind(this); this.mentionList.appendChild( this.attachDataValues(li, datum) ); } this.itemIndex = 0; this.highlightItem(); this.showMentionList(); } else { this.hideMentionList(); } } nextItem() { this.itemIndex = (this.itemIndex + 1) % this.values.length; this.suspendMouseEnter = true; this.highlightItem(); } prevItem() { this.itemIndex = ((this.itemIndex + this.values.length) - 1) % this.values.length; this.suspendMouseEnter = true; this.highlightItem(); } private hasValidChars(s: string) { return this.options.allowedChars.test(s); } private containerBottomIsNotVisible(topPos: number, containerPos: ClientRect | DOMRect) { const mentionContainerBottom = topPos + this.mentionContainer.offsetHeight + containerPos.top; return mentionContainerBottom > window.pageYOffset + window.innerHeight; } private containerRightIsNotVisible(leftPos: number, containerPos: ClientRect | DOMRect) { if (this.options.fixMentionsToQuill) { return false; } const rightPos = leftPos + this.mentionContainer.offsetWidth + containerPos.left; const browserWidth = window.pageXOffset + document.documentElement.clientWidth; return rightPos > browserWidth; } private setIsOpen(isOpen: boolean) { if (this.isOpen !== isOpen) { if (isOpen) { this.options.onOpen(); } else { this.options.onClose(); } this.isOpen = isOpen; } } private setMentionContainerPosition() { const containerPos = this.quill.container.getBoundingClientRect(); /// vv Here we always trigger from the cursor. if (this.cursorPos === undefined) { throw new Error(`Invalid this.cursorPos`); } const mentionCharPos = this.quill.getBounds(this.cursorPos); const containerHeight = this.mentionContainer.offsetHeight; let topPos = this.options.offsetTop; let leftPos = this.options.offsetLeft; // handle horizontal positioning if (this.options.fixMentionsToQuill) { const rightPos = 0; this.mentionContainer.style.right = `${rightPos}px`; } else { leftPos += mentionCharPos.left; } if (this.containerRightIsNotVisible(leftPos, containerPos)) { const containerWidth = this.mentionContainer.offsetWidth + this.options.offsetLeft; const quillWidth = containerPos.width; leftPos = quillWidth - containerWidth; } // handle vertical positioning if (this.options.defaultMenuOrientation === 'top') { // Attempt to align the mention container with the top of the quill editor if (this.options.fixMentionsToQuill) { topPos = -1 * (containerHeight + this.options.offsetTop); } else { topPos = mentionCharPos.top - (containerHeight + this.options.offsetTop); } // default to bottom if the top is not visible if (topPos + containerPos.top <= 0) { let overMentionCharPos = this.options.offsetTop; if (this.options.fixMentionsToQuill) { overMentionCharPos += containerPos.height; } else { overMentionCharPos += mentionCharPos.bottom; } topPos = overMentionCharPos; } } else { // Attempt to align the mention container with the bottom of the quill editor if (this.options.fixMentionsToQuill) { topPos += containerPos.height; } else { topPos += mentionCharPos.bottom; } // default to the top if the bottom is not visible if (this.containerBottomIsNotVisible(topPos, containerPos)) { let overMentionCharPos = this.options.offsetTop * -1; if (!this.options.fixMentionsToQuill) { overMentionCharPos += mentionCharPos.top; } topPos = overMentionCharPos - containerHeight; } } this.mentionContainer.style.top = `${topPos}px`; this.mentionContainer.style.left = `${leftPos}px`; this.mentionContainer.style.visibility = 'visible'; } /** * HF Helpers for manual trigger */ setCursorPos() { const range = this.quill.getSelection(); if (range) { this.cursorPos = range.index; } else { this.quill.setSelection(this.quill.getLength(), 0); /// ^^ place cursor at the end of input by default. this.cursorPos = this.quill.getLength(); } } getCursorPos(): number { return this.cursorPos!; } trigger(values: string[]) { this.renderList("", values.map(x => { return { id: x, value: x }; }), ""); } onSomethingChange() { /// We trigger manually so here we can _probably_ just always close. this.hideMentionList(); } onTextChange(delta: Delta, oldDelta: Delta, source: Sources) { if (source === 'user') { this.onSomethingChange(); } } onSelectionChange(range: RangeStatic) { if (range && range.length === 0) { this.onSomethingChange(); } else { this.hideMentionList(); } } } Quill.register('modules/mention', Mention);