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); | |