import { LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import type { MentionItem } from './nile-rte-mentions-item'; type MentionsConfig = Record; @customElement('nile-rte-mentions') export class NileRteMentions extends LitElement { protected createRenderRoot() { return this; } @property({ type: Boolean, attribute: 'displayformat' , reflect: true}) displayFormat = false; @property({ attribute: 'mentions', converter: { fromAttribute: (value: string): MentionsConfig => { try { const parsed = JSON.parse(value); const out: MentionsConfig = {}; for (const trig of Object.keys(parsed)) { const arr = parsed[trig]; if (Array.isArray(arr)) { out[trig] = arr .filter( i => i && typeof i.key === 'string' && typeof i.label === 'string' ) .map(i => ({ key: i.key, label: i.label })); } } return out; } catch { return {}; } }, toAttribute: (v: MentionsConfig) => JSON.stringify(v), }, }) mentions: MentionsConfig = {}; // nile-rte-mentions.ts (inside class) @property({ attribute: 'mentionformat', converter: { fromAttribute: ( v: string ): Record => { try { const o = JSON.parse(v); return o && typeof o === 'object' ? o : {}; } catch { return {}; } }, toAttribute: (v: Record) => JSON.stringify(v ?? {}), }, }) mentionFormat: Record = {}; @state() private itemsFormats: Record< string, { prefix?: string; suffix?: string } > = {}; private getTriggerFormat(trigger: string): { prefix: string; suffix: string; } { const fromItems = this.itemsFormats[trigger] || {}; const fromAttr = this.mentionFormat[trigger] || {}; return { prefix: fromItems.prefix ?? fromAttr.prefix ?? '', suffix: fromItems.suffix ?? fromAttr.suffix ?? '', }; } private formatLabel(trigger: string, rawLabel: string): string { const { prefix, suffix } = this.getTriggerFormat(trigger); return `${prefix ?? ''}${rawLabel}${suffix ?? ''}`; } @property({ attribute: 'mentioncolors', converter: { fromAttribute: (value: string): Record => { try { const o = JSON.parse(value); return o && typeof o === 'object' ? o : {}; } catch { return {}; } }, toAttribute: (v: Record) => JSON.stringify(v ?? {}), }, }) mentionColors: Record = {}; @state() private externalConfig: MentionsConfig = {}; @state() private itemsConfig: MentionsConfig = {}; @state() private mentionActiveIndex: number = -1; @state() private itemsColors: Record = {}; private get config(): MentionsConfig { return { ...this.externalConfig, ...this.mentions, ...this.itemsConfig }; } // wired from editor private editorEl: HTMLElement | null = null; private hostEl: HTMLElement | null = null; private dropdownEl: HTMLElement | null = null; private menuEl: HTMLElement | null = null; private lastRange: Range | null = null; private mentionOpen = false; private mentionTrigger: string | null = null; private mentionQuery = ''; private mentionSuggestions: MentionItem[] = []; private mentionFiltered: MentionItem[] = []; private mentionX = 0; private mentionY = 0; private triggerBtn: HTMLElement | null = null; private mo: MutationObserver | null = null; attach(editorEl: HTMLElement, hostEl: HTMLElement) { this.editorEl = editorEl; this.hostEl = hostEl; this.injectStyles(); this.ensureMentionDropdown(); this.rebuildConfigFromChildren(); this.mo = new MutationObserver(() => this.rebuildConfigFromChildren()); this.mo.observe(this, { childList: true, subtree: true, attributes: true }); this.editorEl.addEventListener('input', this.onEditorInput); this.editorEl.addEventListener('mouseup', this.saveSelection); this.editorEl.addEventListener('keyup', this.onEditorKeyUp); this.editorEl.addEventListener('keydown', this.onEditorKeyDown); this.editorEl.addEventListener('scroll', this.repositionMention); document.addEventListener('selectionchange', this.onSelectionChange, true); } detach() { if (this.mo) { this.mo.disconnect(); this.mo = null; } if (this.editorEl) { this.editorEl.removeEventListener('input', this.onEditorInput); this.editorEl.removeEventListener('mouseup', this.saveSelection); this.editorEl.removeEventListener('keyup', this.onEditorKeyUp); this.editorEl.removeEventListener('keydown', this.onEditorKeyDown); this.editorEl.removeEventListener('scroll', this.repositionMention); } document.removeEventListener( 'selectionchange', this.onSelectionChange, true ); } setExternalConfig(cfg: MentionsConfig) { this.externalConfig = cfg || {}; } disconnectedCallback(): void { this.detach(); super.disconnectedCallback(); } private rebuildConfigFromChildren() { const cfg: MentionsConfig = {}; const colors: Record = {}; const formats: Record = {}; const items = Array.from( this.querySelectorAll('nile-rte-mentions-item') ) as Array< HTMLElement & { mentionsCharacter?: string; mentionsData?: MentionItem[]; mentionsColor?: string; } >; for (const item of items) { const trig = (item as any).mentionsCharacter ?? item.getAttribute('mentionscharacter') ?? ''; if (!trig || typeof trig !== 'string') continue; let data: MentionItem[] | null = (item as any).mentionsData ?? (() => { const raw = item.getAttribute('mentionsdata'); if (!raw) return null; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter( i => i && typeof i.key === 'string' && typeof i.label === 'string' ) : []; } catch { return []; } })(); const color = (item as any).mentionsColor ?? item.getAttribute('mentionscolor') ?? ''; const prefix = (item as any).mentionsPrefix ?? item.getAttribute('mentionsprefix') ?? ''; const suffix = (item as any).mentionsSuffix ?? item.getAttribute('mentionssuffix') ?? ''; if (!Array.isArray(data)) data = []; cfg[trig] = data; if (color) colors[trig] = String(color); if (prefix || suffix) formats[trig] = { prefix, suffix }; } this.itemsConfig = cfg; this.itemsColors = colors; this.itemsFormats = formats; } private injectStyles() { if (this.querySelector('style[data-mentions-style]')) return; const style = document.createElement('style'); style.setAttribute('data-mentions-style', 'true'); style.textContent = ` .mention-dropdown { position: absolute; z-index: 1000; list-style: none; margin: 0; padding: 4px; border: 1px solid #e5e7eb; border-radius: 6px; background: #fff; box-shadow: 0 4px 10px rgba(0,0,0,.08); max-height: 180px; overflow: auto; font-size: 14px; } .mention-dropdown li { padding: 6px 8px; cursor: pointer; border-radius: 4px; } .mention-dropdown li:hover { background: #f1f5f9; } .mention { background: #eef2ff; padding: 0 3px; border-radius: 3px; } nile-menu.mentions-menu::part(menu__items-wrapper){ max-height: 260px; overflow-y: auto; } .mentions-menu nile-menu-item.active { background: #dbeafe; /* highlight */ outline: none; } `; this.insertBefore(style, this.firstChild); } private getTriggerColor(trigger: string): string | undefined { return this.itemsColors[trigger] ?? this.mentionColors[trigger]; } private ensureMentionDropdown() { if (this.dropdownEl || !this.hostEl) return; const dd = document.createElement('nile-dropdown'); dd.style.zIndex = '1000'; const btn = document.createElement('nile-button'); btn.setAttribute('slot', 'trigger'); btn.style.position = 'absolute'; btn.style.width = '1px'; btn.style.height = '1px'; btn.style.border = '0'; btn.style.padding = '0'; btn.style.opacity = '0'; btn.style.pointerEvents = 'none'; dd.appendChild(btn); this.triggerBtn = btn; const menu = document.createElement('nile-menu'); menu.classList.add('mentions-menu'); dd.appendChild(menu); this.hostEl.appendChild(dd); this.dropdownEl = dd; this.menuEl = menu; } private onSelectionChange = () => { if (!this.editorEl) return; const sel = document.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); if (this.editorEl.contains(range.commonAncestorContainer)) { this.lastRange = range.cloneRange(); } }; private saveSelection = () => { const sel = window.getSelection(); if (sel && sel.rangeCount) this.lastRange = sel.getRangeAt(0).cloneRange(); }; private restoreSelection() { if (!this.lastRange) return; const sel = document.getSelection(); if (!sel) return; sel.removeAllRanges(); sel.addRange(this.lastRange); } private getCaretClientRect(): DOMRect | null { const sel = document.getSelection(); if (!sel || sel.rangeCount === 0) return null; const range = sel.getRangeAt(0).cloneRange(); range.collapse(false); const rects = range.getClientRects(); if (rects && rects.length > 0) return rects[0]; const marker = document.createElement('span'); marker.setAttribute('data-caret-marker', '1'); marker.textContent = '\u200B'; range.insertNode(marker); const rect = marker.getBoundingClientRect(); const after = document.createRange(); after.setStartAfter(marker); after.collapse(true); sel.removeAllRanges(); sel.addRange(after); marker.remove(); return rect || null; } private onEditorInput = () => { if (this.mentionOpen) { this.handleMention(); return; } const sel = window.getSelection(); if (!sel || !sel.anchorNode) return; let node: Node | null = sel.anchorNode; if (node.nodeType !== Node.TEXT_NODE && node.firstChild?.nodeType === Node.TEXT_NODE) { node = node.firstChild; } if (!node || node.nodeType !== Node.TEXT_NODE) return; const text = node.textContent || ''; const cfg = this.config; for (const trigger of Object.keys(cfg)) { const triggerIndex = text.lastIndexOf(trigger); if (triggerIndex !== -1) { const caretPos = sel.focusOffset; if (caretPos >= triggerIndex + trigger.length && caretPos <= triggerIndex + trigger.length + 1) { this.openMention(trigger); return; } } } }; private onEditorKeyUp = (e: KeyboardEvent) => { this.saveSelection(); const key = e.key; const cfg = this.config; if (key && cfg[key]) { if (this.mentionOpen && this.mentionTrigger !== key) { this.closeMention(); } this.openMention(key); return; } if (this.mentionOpen) { this.handleMention(); } }; private onEditorKeyDown = (e: KeyboardEvent) => { if (!this.mentionOpen) return; const key = e.key; if (key === 'ArrowDown' || key === 'ArrowUp') { e.preventDefault(); if (!this.mentionFiltered.length) return; if (key === 'ArrowDown') { this.mentionActiveIndex = (this.mentionActiveIndex + 1) % this.mentionFiltered.length; } else { this.mentionActiveIndex = (this.mentionActiveIndex - 1 + this.mentionFiltered.length) % this.mentionFiltered.length; } this.renderMentionList(); } else if (key === 'Enter') { if ( this.mentionActiveIndex >= 0 && this.mentionFiltered[this.mentionActiveIndex] ) { e.preventDefault(); this.selectMention(this.mentionFiltered[this.mentionActiveIndex]); } } else if (key === 'Escape') { e.preventDefault(); this.closeMention(); } }; private openMention(trigger: string) { this.saveSelection(); if (!this.editorEl || !this.dropdownEl || !this.hostEl) return; const caretRect = this.getCaretClientRect(); if (!caretRect) return; const hostRect = this.hostEl.getBoundingClientRect(); this.mentionX = caretRect.left - hostRect.left; this.mentionY = caretRect.bottom - hostRect.top; this.mentionTrigger = trigger; this.mentionSuggestions = this.config[trigger] || []; this.mentionQuery = ''; this.updateMentionFiltered(); this.mentionActiveIndex = this.mentionFiltered.length ? 0 : -1; this.mentionOpen = true; this.renderMentionList(); this.dispatchEvent( new CustomEvent('nile-mention-opened', { detail: { trigger, suggestions: this.mentionSuggestions }, bubbles: true, composed: true, }) ); } private closeMention() { this.mentionOpen = false; this.mentionTrigger = null; this.mentionQuery = ''; this.mentionFiltered = []; // hide dropdown this.dropdownEl?.removeAttribute('open'); } private handleMention() { if (!this.mentionOpen || !this.mentionTrigger) return; const sel = window.getSelection(); if (!sel || !sel.anchorNode) return; let node: Node | null = sel.anchorNode; if (node.nodeType !== Node.TEXT_NODE) { const c = (node as HTMLElement).childNodes?.[sel.anchorOffset] || node.firstChild; if (c?.nodeType === Node.TEXT_NODE) node = c; } const text = node && node.nodeType === Node.TEXT_NODE ? node.textContent || '' : ''; const idx = text.lastIndexOf(this.mentionTrigger); if (idx >= 0) { this.mentionQuery = text.substring(idx + 1); this.updateMentionFiltered(); this.renderMentionList(); } else { this.closeMention(); } } private updateMentionFiltered() { const q = this.mentionQuery.toLowerCase(); this.mentionFiltered = (this.mentionSuggestions || []).filter(item => item.label.toLowerCase().startsWith(q) ); } private renderMentionList() { if (!this.dropdownEl || !this.menuEl) return; if (!this.triggerBtn) return; this.triggerBtn.style.left = `${this.mentionX}px`; this.triggerBtn.style.top = `${this.mentionY}px`; this.menuEl.innerHTML = ''; if (!this.mentionOpen || !this.mentionFiltered.length) { this.dropdownEl.removeAttribute('open'); return; } this.mentionFiltered.forEach((item, idx) => { const mi = document.createElement('nile-menu-item'); mi.textContent = item.label; mi.setAttribute('role', 'option'); if (idx === this.mentionActiveIndex) { mi.classList.add('active'); mi.setAttribute('aria-selected', 'true'); } else { mi.classList.remove('active'); mi.removeAttribute('aria-selected'); } mi.addEventListener('mouseenter', () => { this.mentionActiveIndex = idx; this.renderMentionList(); }); mi.addEventListener('pointerdown', e => { e.preventDefault(); this.selectMention(item); }); this.menuEl?.appendChild(mi); }); this.dropdownEl.setAttribute('open', ''); } private repositionMention = () => { if (!this.mentionOpen || !this.hostEl || !this.triggerBtn) return; const caretRect = this.getCaretClientRect(); if (!caretRect) return; const hostRect = this.hostEl.getBoundingClientRect(); this.mentionX = caretRect.left - hostRect.left; this.mentionY = caretRect.bottom - hostRect.top; this.triggerBtn.style.left = `${this.mentionX}px`; this.triggerBtn.style.top = `${this.mentionY}px`; }; private selectMention(item: MentionItem) { this.restoreSelection(); if (!this.lastRange || !this.mentionTrigger || !this.editorEl) return; const trigger = this.mentionTrigger; let node: Node = this.lastRange.startContainer; let offset = this.lastRange.startOffset; if (node.nodeType !== Node.TEXT_NODE) { const child = node.childNodes[offset]; if (child?.nodeType === Node.TEXT_NODE) { node = child; offset = 0; } else return; } const textNode = node as Text; const text = textNode.textContent || ''; const atIndex = text.lastIndexOf(this.mentionTrigger, offset); if (atIndex < 0) return; const endIndex = atIndex + 1 + this.mentionQuery.length; const mentionRange = document.createRange(); mentionRange.setStart(textNode, atIndex); mentionRange.setEnd(textNode, endIndex); mentionRange.deleteContents(); const span = document.createElement('span'); span.classList.add('mention'); const fmt = this.getTriggerFormat(this.mentionTrigger); if (this.displayFormat) { span.textContent = `${fmt.prefix ?? ''}${item.label}${fmt.suffix ?? ''}`; } else { span.textContent = `${this.mentionTrigger}${item.label}`; if (fmt.prefix) span.setAttribute('data-mention-prefix', fmt.prefix); if (fmt.suffix) span.setAttribute('data-mention-suffix', fmt.suffix); } span.setAttribute('data-mention-key', item.key); span.setAttribute('data-mention-label', item.label); span.setAttribute('data-mention-trigger', this.mentionTrigger!); const bg = this.getTriggerColor(this.mentionTrigger); if (bg) span.style.backgroundColor = bg; mentionRange.insertNode(span); const spaceNode = document.createTextNode('\u00A0'); span.after(spaceNode); const sel = window.getSelection(); const afterRange = document.createRange(); afterRange.setStartAfter(spaceNode); afterRange.collapse(true); sel?.removeAllRanges(); sel?.addRange(afterRange); this.lastRange = afterRange.cloneRange(); this.closeMention(); this.dispatchEvent( new CustomEvent('nile-mention-selected', { detail: { trigger, key: item.key, label: item.label, prefix: fmt.prefix ?? '', suffix: fmt.suffix ?? '', }, bubbles: true, composed: true, }) ); this.editorEl.dispatchEvent(new InputEvent('input', { bubbles: true })); } } declare global { interface HTMLElementTagNameMap { 'nile-rte-mentions': NileRteMentions; } }