import './headless-emoji.component';
import {html} from 'lit';
import {init, SearchIndex} from 'emoji-mart';
import {litToHTML} from '../../../../../utilities/lit-to-html';
import data from '@emoji-mart/data';
import Quill from 'quill';
import type {EmojiResult} from "../emoji";
import type {ResultItem} from './headless-emoji.component';
import type HeadlessEmojiComponent from './headless-emoji.component';
class HeadlessEmoji {
private _quill: Quill;
private _component!: HeadlessEmojiComponent;
private _startIndex = -1;
private _keydownHandler = (e: KeyboardEvent) => this.onKeydown(e);
private _docClickHandler = (e: MouseEvent) => this.onDocumentClick(e);
constructor(quill: Quill) {
this._quill = quill;
// Initialize emoji-mart index once
try {
void init({data});
} catch {
// no-op
}
this.initComponent();
this.attachEvents();
}
private initComponent() {
this._component = this.createComponent()!;
this._quill.container.ownerDocument.body.appendChild(this._component);
}
private attachEvents() {
this._quill.on(Quill.events.TEXT_CHANGE, () => this.updateFromEditor());
this._quill.on(Quill.events.EDITOR_CHANGE, () => this.updateFromEditor());
this._quill.root.addEventListener('keydown', this._keydownHandler);
this._component.addEventListener('zn-emoji-select', (e: Event) => this.onEmojiSelect(e as CustomEvent));
this._quill.on('editor-change', () => this.positionComponent());
this._quill.focus();
}
private createComponent() {
const tpl = html`
`;
return litToHTML(tpl);
}
private onDocumentClick(e: MouseEvent) {
const target = e.composedPath ? e.composedPath()[0] as Node : (e.target as Node);
if (!target) return;
if (!this._component.contains(target) && !this._quill.root.contains(target)) {
this.hide();
}
}
private async updateFromEditor() {
const info = this.getEmojiQuery();
if (!info) {
this.hide();
return;
}
const {start, emojiQuery} = info;
this._startIndex = start;
try {
const results = await SearchIndex.search(emojiQuery) as EmojiResult[];
const mapped: ResultItem[] = (Array.isArray(results) ? results : []).slice(0, 20).map((e) => ({
emojiChar: (e?.skins?.[0]?.native) || e?.native || '',
label: (e?.id || e?.shortcodes || '') || ''
})).filter(it => !!it.emojiChar);
this._component.results = mapped;
this._component.query = emojiQuery;
if (mapped.length > 0) {
this.show();
this.positionComponent();
} else {
this.show();
this.positionComponent();
}
} catch {
this._component.results = [];
this._component.query = emojiQuery;
this.show();
this.positionComponent();
}
}
private positionComponent() {
if (!this._component || !this._component.open) return;
const range = this._quill.getSelection();
if (!range) return;
const bounds = this._quill.getBounds(range.index);
if (!bounds) return;
const editorBounds = this._quill.container.getBoundingClientRect();
const left = editorBounds.left + Math.max(0, bounds.left);
const top = editorBounds.top + bounds.bottom + 4;
this._component.setPosition(left, top);
}
private getEmojiQuery(): { start: number; emojiQuery: string } | null {
try {
const sel = this._quill.getSelection();
if (!sel) return null;
const cursor = sel.index;
const characterLimit = 50;
const textBefore = this._quill.getText(Math.max(0, cursor - characterLimit), Math.min(characterLimit, cursor));
const offset = cursor - Math.max(0, cursor - characterLimit);
const uptoCursor = textBefore.slice(0, offset);
const cIndex = uptoCursor.lastIndexOf(':');
if (cIndex === -1) return null;
const prev = cIndex > 0 ? uptoCursor[cIndex - 1] : ' ';
if (prev && /[^\s\n]/.test(prev)) return null; // must start at word boundary
const emojiQuery = uptoCursor.substring(cIndex + 1);
if (/[\s\n]/.test(emojiQuery)) return null; // stop at whitespace
// Do not trigger for URLs like http://
if (/^\/\//.test(uptoCursor.substring(Math.max(0, cIndex - 5), cIndex + 1))) return null;
return {start: cursor - emojiQuery.length - 1, emojiQuery};
} catch {
return null;
}
}
private onKeydown(e: KeyboardEvent) {
if (!this._component?.open) return;
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
this.hide();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
const next = (this._component.getActiveIndex?.() ?? -1) + 1;
this._component.setActiveIndex?.(next);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
const prev = (this._component.getActiveIndex?.() ?? 0) - 1;
this._component.setActiveIndex?.(prev);
return;
}
if (e.key === 'Enter') {
const idx = this._component.getActiveIndex?.();
const results = this._component.results || [];
const item = (typeof idx === 'number' && idx >= 0 && idx < results.length) ? results[idx] : undefined;
if (item) {
e.preventDefault();
e.stopPropagation();
this.replaceAtQuery(item.emojiChar);
}
}
}
private onEmojiSelect(e: CustomEvent) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access
const emojiChar: string = e.detail?.emojiChar || '';
if (!emojiChar) return;
this.replaceAtQuery(emojiChar);
}
private replaceAtQuery(emojiChar: string) {
try {
const sel = this._quill.getSelection();
if (!sel || this._startIndex < 0) return;
const insertIndex = this._startIndex;
const length = sel.index - insertIndex;
if (length < 0) return;
// Remove ':' and the query
this._quill.deleteText(insertIndex, length, Quill.sources.USER);
const insertText = `${emojiChar} `;
this._quill.insertText(insertIndex, insertText, Quill.sources.USER);
const cursorPos = insertIndex + insertText.length;
setTimeout(() => this._quill.setSelection(cursorPos, 0, Quill.sources.SILENT), 0);
this._quill.focus();
this.hide();
} catch {
// no-op
}
}
private show() {
if (!this._component.open) {
this._component.show();
this._quill.container.ownerDocument.addEventListener('click', this._docClickHandler);
}
}
private hide() {
this._component.hide();
this._startIndex = -1;
this._quill.container.ownerDocument.removeEventListener('click', this._docClickHandler);
}
public isOpen() {
return this._component?.open;
}
}
export default HeadlessEmoji;