XciD's picture
XciD HF staff
initial commit
8969f81
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);