Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
| 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 = `<a href="${link}" target=${itemTarget || this.options.linkTarget}>${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); | |