import { LitElement, PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import './nile-rte-toolbar'; import './nile-rte-toolbar-item'; import './nile-rte-select'; import './nile-rte-color'; import './nile-rte-divider'; import './nile-rte-preview'; import './nile-rte-mentions'; import './nile-rte-link'; import { closestBlock, nearestElement, rgbToHex, toggleInlineTag, setBlockTag, setAlignment, setFontFamily, setForeColor, setBackColor, toggleList, } from './utils/index'; import { styles } from './nile-rich-text-editor.css'; type MentionsConfig = Record; const DEFAULT_ICONS: Record = { bold: 'format_bold', italic: 'format_italic', underline: 'format_underline', link: 'link_2', left: 'format_align_left', center: 'format_align_middle', right: 'format_align_right', justify: 'format_align_justify', ul: 'format_list_bulleted', ol: 'format_list_numbered', clear: 'format_clear' }; const DEFAULT_IMPORTANT_PROPS = [ 'font-weight', 'font-style', 'text-decoration', 'color', 'background-color', 'font-size', 'font-family', 'text-align', 'line-height', 'letter-spacing', 'white-space', 'vertical-align', 'list-style-position', 'padding-inline-start', ]; @customElement('nile-rich-text-editor') export class NileRichTextEditor extends LitElement { protected createRenderRoot() { return this; } @property({ type: String, attribute: true, reflect: true }) value = ''; @property({ type: Boolean, attribute: true, reflect: true }) noStyles = false; @property({ type: Boolean, attribute: true, reflect: true }) disabled = false; @property({ type: Boolean, attribute: 'singlelineeditor', reflect: true }) singleLineEditor = false; @property({ type: String }) placeholder = ''; @property({ attribute: 'mentions', reflect: true, 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 = {}; @property({ attribute: 'whitelist', reflect: false, converter: { fromAttribute: (value: string): string[] => { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : []; } catch { return []; } }, }, }) whitelist: string[] = []; @state() private content = ''; private editorEl!: HTMLElement; private previewEl: HTMLElement | null = null; private toolbarEl: HTMLElement | null = null; private lastRange: Range | null = null; private buttonMap = new Map(); private headingSelect: HTMLSelectElement | null = null; private fontSelect: HTMLSelectElement | null = null; private colorInput: HTMLInputElement | null = null; private bgColorInput: HTMLInputElement | null = null; private colorSwatchEl: HTMLElement | null = null; private bgSwatchEl: HTMLElement | null = null; private containerEl: HTMLElement | null = null; private linkEl: HTMLElement | null = null; private mentionsEl: HTMLElement | null = null; private injectCss(cssText: string) { if (this.querySelector('style[data-rte-style]')) return; const style = document.createElement('style'); style.setAttribute('data-rte-style', 'true'); style.textContent = cssText; this.insertBefore(style, this.firstChild); } connectedCallback(): void { super.connectedCallback(); this.injectCss(styles.cssText); this.ensureStructure(); if (this.placeholder && this.editorEl) { this.editorEl.dataset.placeholder = this.placeholder; } if (this.value && !this.editorEl.innerHTML.trim()) { this.editorEl.innerHTML = this.value; } this.content = this.editorEl.innerHTML; if (this.toolbarEl) this.wireAuthoredToolbar(this.toolbarEl); this.mentionsEl = this.querySelector('nile-rte-mentions'); if (this.mentionsEl) { (this.mentionsEl as any).attach?.(this.editorEl, this as HTMLElement); (this.mentionsEl as any).setExternalConfig?.(this.mentions); } this.wireEditor(); document.addEventListener('selectionchange', this.onSelectionChange, { passive: true, }); this.updateToolbarState(); this.syncPreview(); } private ensureStructure() { this.toolbarEl = this.querySelector('nile-rte-toolbar'); this.previewEl = this.querySelector('nile-rte-preview'); // container for toolbar + editor this.containerEl = this.querySelector('.rte-container') as HTMLElement; if (!this.containerEl) { this.containerEl = document.createElement('div'); this.containerEl.className = 'rte-container'; this.appendChild(this.containerEl); } // ensure editor this.ensureEditor(); // put toolbar + editor inside container if (this.toolbarEl && this.toolbarEl.parentElement !== this.containerEl) { this.containerEl.appendChild(this.toolbarEl); } if (this.editorEl.parentElement !== this.containerEl) { this.containerEl.appendChild(this.editorEl); } if (this.previewEl) { if (this.previewEl.parentElement !== this) { this.appendChild(this.previewEl); } if (this.previewEl.previousElementSibling !== this.containerEl) { this.insertBefore(this.previewEl, this.containerEl.nextSibling); } } } protected firstUpdated(_changedProperties: PropertyValues): void { super.firstUpdated(_changedProperties); if (this.editorEl) { const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); if (isSafari) { this.editorEl.classList.add('safari'); } } } private updateContentWithMention(mentionDetail: any) { this.updateContent(); this.dispatchEvent( new CustomEvent('nile-change', { detail: { content: this.content, mention: mentionDetail, }, bubbles: true, composed: true, }) ); } disconnectedCallback(): void { this.unwireEditor(); document.removeEventListener('selectionchange', this.onSelectionChange); if (this.mentionsEl && (this.mentionsEl as any).detach) { (this.mentionsEl as any).detach(); } super.disconnectedCallback(); } protected updated(changed: Map) { if (changed.has('value')) { if (this.editorEl && this.value !== this.editorEl.innerHTML) { this.editorEl.innerHTML = this.value || '


'; this.content = this.editorEl.innerHTML; this.syncPreview(); } } if (changed.has('singleLineEditor')) { if (this.editorEl) { this.editorEl.classList.toggle('single-line', this.singleLineEditor); } if (this.previewEl) { this.previewEl.classList.toggle('single-line', this.singleLineEditor); } } if (changed.has('disabled') && this.editorEl) { this.editorEl.setAttribute('contenteditable', this.disabled ? 'false' : 'true'); this.editorEl.tabIndex = this.disabled ? -1 : 0; if (this.linkEl) { (this.linkEl as any).disabled = this.disabled; } } if (changed.has('placeholder') && this.editorEl) { if (this.placeholder) { this.editorEl.dataset.placeholder = this.placeholder; } else { delete this.editorEl.dataset.placeholder; } } } private ensureEditor() { this.editorEl = this.querySelector('.editor') as HTMLElement; if (!this.editorEl) { const editor = document.createElement('article'); editor.className = 'editor'; editor.setAttribute('contenteditable', this.disabled ? 'false' : 'true'); editor.tabIndex = this.disabled ? -1 : 0; this.editorEl = editor; } if (this.placeholder) { this.editorEl.dataset.placeholder = this.placeholder; } if (!this.editorEl.innerHTML.trim()) { this.editorEl.innerHTML = '


'; } } private unwrapMention(span: HTMLElement, preserveText = true) { const parent = span.parentNode; if (!parent) return; const txt = preserveText ? (span.textContent ?? '').replace(/\u200B/g, '') : ''; const textNode = document.createTextNode(txt); parent.insertBefore(textNode, span); parent.removeChild(span); const r = document.createRange(); r.setStartAfter(textNode); r.collapse(true); const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(r); } private scrubBrokenMentions() { if (!this.editorEl) return; const mentions = this.editorEl.querySelectorAll('span.mention'); mentions.forEach(m => { const span = m as HTMLElement; const key = span.getAttribute('data-mention-key'); const label = span.getAttribute('data-mention-label'); const trigger = span.getAttribute('data-mention-trigger') || ''; const text = (span.textContent ?? '').replace(/\u200B/g, '').trim(); const looksValid = !!key && !!label && text.length > 0 && text.startsWith(trigger) && text.includes(label); if (!text || !looksValid) { this.unwrapMention(span, true); return; } }); } private onEditorClick = (e: MouseEvent) => { if (this.disabled) { e.preventDefault(); return; } const anchor = (e.target as HTMLElement).closest?.('a'); if (anchor) e.preventDefault(); }; private onEditorPaste = (e: ClipboardEvent) => { if (this.disabled) e.preventDefault(); }; private onEditorDrop = (e: DragEvent) => { if (this.disabled) e.preventDefault(); }; private onEditorInput = () => { if (this.disabled) return; this.ensureAtLeastOneParagraph(); this.scrubBrokenMentions(); this.updateContent(); this.updateToolbarState(); }; private onEditorMouseup = () => { this.saveSelection(); this.updateToolbarState(); }; private onEditorKeyup = (e: KeyboardEvent) => { this.saveSelection(); if ( [ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', ].includes(e.key) ) { this.updateToolbarState(); } }; private wireEditor() { this.editorEl.addEventListener('click', this.onEditorClick); this.editorEl.addEventListener('input', this.onEditorInput); this.editorEl.addEventListener('mouseup', this.onEditorMouseup); this.editorEl.addEventListener('keyup', this.onEditorKeyup); this.editorEl.addEventListener('keydown', this.onEditorKeydown); this.editorEl.addEventListener('paste', this.onEditorPaste); this.editorEl.addEventListener('drop', this.onEditorDrop); } private unwireEditor() { if (!this.editorEl) return; this.editorEl.removeEventListener('click', this.onEditorClick); this.editorEl.removeEventListener('input', this.onEditorInput); this.editorEl.removeEventListener('mouseup', this.onEditorMouseup); this.editorEl.removeEventListener('keyup', this.onEditorKeyup); this.editorEl.removeEventListener('keydown', this.onEditorKeydown); this.editorEl.removeEventListener('paste', this.onEditorPaste); this.editorEl.removeEventListener('drop', this.onEditorDrop); } private onEditorKeydown = (e: KeyboardEvent) => { if (this.disabled) { e.preventDefault(); return; } if (this.singleLineEditor && e.key === 'Enter' && e.shiftKey) { e.preventDefault(); return; } if (e.ctrlKey && e.key.toLowerCase() === 'i') { e.preventDefault(); toggleInlineTag(this.editorEl, 'em'); this.updateContent(); this.updateToolbarState(); } if (this.singleLineEditor && e.key === 'Enter') { e.preventDefault(); return; } if (e.key !== 'Tab') return; e.preventDefault(); this.focusAndRestore(); const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); if (e.shiftKey) { if (range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE) { const t = range.startContainer as Text; const off = range.startOffset; const before = t.data.slice(0, off); const removed = before.replace(/(\t|[ \u00a0]{2})$/, ''); if (removed.length !== before.length) { t.data = removed + t.data.slice(off); const r = document.createRange(); r.setStart(t, removed.length); r.collapse(true); sel.removeAllRanges(); sel.addRange(r); this.updateContent(); this.updateToolbarState(); } } return; } range.deleteContents(); const tabNode = document.createTextNode('\t'); range.insertNode(tabNode); const r = document.createRange(); r.setStartAfter(tabNode); r.collapse(true); sel.removeAllRanges(); sel.addRange(r); this.updateContent(); this.updateToolbarState(); }; private wireAuthoredToolbar(tb: HTMLElement) { this.buttonMap.clear(); this.headingSelect = null; this.fontSelect = null; this.colorInput = null; Array.from(tb.children).forEach(child => { const tag = child.tagName.toLowerCase(); if (tag === 'nile-rte-select' && child.getAttribute('type') === 'align') { (child as any).disabled = this.disabled; child.addEventListener('change', (e: any) => { this.focusAndRestore(); const alignment = e.detail as 'left' | 'center' | 'right' | 'justify'; setAlignment(this.editorEl, alignment); this.updateContent(); this.updateToolbarState(); }); return; } if (tag === 'nile-rte-link') { (child as any).editorEl = this.editorEl; (child as any).disabled = this.disabled; this.linkEl = child as HTMLElement; child.addEventListener('nile-link-changed', () => { this.updateContent(); this.updateToolbarState(); }); return; } if (tag === 'nile-rte-toolbar-item') { let btn = child.querySelector( ':scope > nile-button' ) as HTMLElement | null; const cmd = child.getAttribute('name') || ''; const label = child.getAttribute('label') || cmd; const iconAttr = child.getAttribute('icon'); const authoredHasContent = child.innerHTML.trim().length > 0; if (!btn) { btn = document.createElement('nile-button'); (btn as any).variant = 'tertiary'; (btn as any).size = 'small'; } (btn as any).disabled = this.disabled; if (iconAttr) { btn.innerHTML = ``; child.innerHTML = ''; } else if (!authoredHasContent) { const defaultIcon = DEFAULT_ICONS[cmd]; if (defaultIcon) { btn.innerHTML = ``; } else { btn.textContent = label || cmd; } child.innerHTML = ''; } else { btn.innerHTML = child.innerHTML; child.innerHTML = ''; } if (!btn.isConnected) { if (this.disabled) { child.appendChild(btn); } else { const tooltip = document.createElement('nile-lite-tooltip'); tooltip.setAttribute('content', label); tooltip.appendChild(btn); child.appendChild(tooltip); } } btn.setAttribute('aria-label', label); btn.addEventListener('mousedown', e => e.preventDefault()); btn.addEventListener('click', () => this.onToolbarCommand(cmd)); const arr = this.buttonMap.get(cmd) ?? []; arr.push(btn); this.buttonMap.set(cmd, arr); return; } if (tag === 'nile-rte-select') { const type = child.getAttribute('type') || ''; child.addEventListener('change', (e: any) => { this.focusAndRestore(); const val = e.detail as string; if (type === 'heading') { setBlockTag(this.editorEl, val as any); } else if (type === 'font') { setFontFamily(this.editorEl, val); } this.updateContent(); this.updateToolbarState(); }); return; } if (tag === 'nile-rte-color') { child.addEventListener('change', (e: any) => { this.focusAndRestore(); const { mode, value } = e.detail; if (mode === 'backColor') { setBackColor(this.editorEl, value); } else { setForeColor(this.editorEl, value); } this.updateContent(); this.updateToolbarState(); }); return; } }); } 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(); this.updateToolbarState(); } }; 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 focusAndRestore() { this.editorEl?.focus(); this.restoreSelection(); } private getCleanContent(): string { const clone = this.editorEl.cloneNode(true) as HTMLElement; clone.querySelectorAll('*').forEach(el => { el.removeAttribute('style'); }); return clone.innerHTML; } private insertList(type: 'ul' | 'ol') { this.restoreSelection(); if (!this.lastRange) return; const list = document.createElement(type); // grab the selected fragment const frag = this.lastRange.extractContents(); const temp = document.createElement('div'); temp.appendChild(frag); // wrap each top-level node in an
  • Array.from(temp.childNodes).forEach(n => { // skip empty whitespace text nodes if (n.nodeType === Node.TEXT_NODE && !n.textContent?.trim()) return; const li = document.createElement('li'); li.appendChild(n); list.appendChild(li); }); this.lastRange.insertNode(list); this.afterListEdit(list); } private afterListEdit(node: Node) { const range = document.createRange(); range.setStartAfter(node); range.collapse(true); const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); this.saveSelection(); this.updateContent(); this.updateToolbarState(); } private ensureAtLeastOneParagraph() { const el = this.editorEl; if (!el) return; const onlyWhitespace = (el.textContent ?? '').replace(/\u200B/g, '').trim() === ''; if (el.childNodes.length === 0 || onlyWhitespace) { el.innerHTML = '


    '; const p = el.querySelector('p'); if (p) { const range = document.createRange(); range.setStart(p, 0); range.collapse(true); const sel = window.getSelection(); if (sel) { sel.removeAllRanges(); sel.addRange(range); } } return; } const hasBlock = el.querySelector( 'p,h1,h2,h3,h4,h5,h6,ul,ol,table,blockquote,pre' ); if (!hasBlock) { const p = document.createElement('p'); while (el.firstChild) p.appendChild(el.firstChild); if (!p.hasChildNodes()) p.appendChild(document.createElement('br')); el.appendChild(p); return; } el.querySelectorAll('p').forEach(p => { if ((p.textContent ?? '').replace(/\u200B/g, '') === '') { if (!p.innerHTML.toLowerCase().includes(' { let n: Node | null = startElm; while (n && n !== this.editorEl) { if (n instanceof HTMLElement) { const t = n.tagName.toLowerCase(); if (t === 'strong' || t === 'b') return true; const w = getComputedStyle(n).fontWeight; if (parseInt(w, 10) >= 600) return true; } n = n.parentNode; } return false; })(); const isItalic = (() => { let n: Node | null = startElm; while (n && n !== this.editorEl) { if (n instanceof HTMLElement) { const t = n.tagName.toLowerCase(); if (t === 'em' || t === 'i') return true; if (getComputedStyle(n).fontStyle === 'italic') return true; } n = n.parentNode; } return false; })(); const isUnderline = (() => { let n: Node | null = startElm; while (n && n !== this.editorEl) { if (n instanceof HTMLElement) { const tdl = getComputedStyle(n).textDecorationLine; if (tdl && tdl.includes('underline')) return true; if (n.tagName.toLowerCase() === 'u') return true; } n = n.parentNode; } return false; })(); const inLink = !!startElm.closest('a'); const align = (block.style.textAlign || getComputedStyle(block).textAlign || 'start') as string; const alignNorm = align === 'start' ? 'left' : align; const inLi = !!startElm.closest('li'); const listType = inLi ? startElm.closest('ul,ol')?.tagName.toLowerCase() || '' : ''; this.setBtnActive('bold', isBold); this.setBtnActive('italic', isItalic); this.setBtnActive('underline', isUnderline); this.setBtnActive('link', inLink); if (this.linkEl) { const linkBtn = this.linkEl.querySelector('nile-button'); if (linkBtn) linkBtn.toggleAttribute('data-active', inLink); } this.setBtnActive( 'left', alignNorm === 'left' && !['center', 'right', 'justify'].includes(alignNorm) ); this.setBtnActive('center', alignNorm === 'center'); this.setBtnActive('right', alignNorm === 'right'); this.setBtnActive('justify', alignNorm === 'justify'); this.setBtnActive('ul', listType === 'ul'); this.setBtnActive('ol', listType === 'ol'); if (this.headingSelect) { const tag = block.tagName.toLowerCase(); const v = ['h1', 'h2', 'h3'].includes(tag) ? tag : 'p'; if (this.headingSelect.value !== v) this.headingSelect.value = v; } if (this.fontSelect) { const ff = (comp.fontFamily || '') .replace(/["']/g, '') .split(',')[0] .trim() .toLowerCase(); if (ff) { for (const opt of Array.from(this.fontSelect.options)) { if (opt.value.toLowerCase() === ff) { this.fontSelect.value = opt.value; break; } } } } if (this.colorInput) { const hex = rgbToHex(comp.color); if (hex && this.colorInput.value.toLowerCase() !== hex.toLowerCase()) { this.colorInput.value = hex; } if (this.colorSwatchEl) this.colorSwatchEl.style.backgroundColor = this.colorInput.value; } if (this.bgColorInput) { const bg = getComputedStyle(startElm).backgroundColor; if ( bg && !/transparent|rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)/i.test(bg) ) { const bgHex = rgbToHex(bg); if ( bgHex && this.bgColorInput.value.toLowerCase() !== bgHex.toLowerCase() ) { this.bgColorInput.value = bgHex; } } if (this.bgSwatchEl) this.bgSwatchEl.style.backgroundColor = this.bgColorInput.value; } } private syncPreview() { this.updateContent(); } private getComputedImportantProps(): string[] { return Array.from( new Set([ ...DEFAULT_IMPORTANT_PROPS, ...(this.whitelist ?? []), ]) ); } private updateContent() { if (!this.editorEl) return; this.ensureAtLeastOneParagraph(); const isEmpty = (this.editorEl.textContent ?? '').trim() === ''; this.editorEl.classList.toggle('empty', isEmpty); const sel = window.getSelection(); const caretRange = sel && sel.rangeCount > 0 && this.editorEl.contains(sel.getRangeAt(0).startContainer) ? sel.getRangeAt(0).cloneRange() : null; const clone = this.editorEl.cloneNode(true) as HTMLElement; if (!this.noStyles) { const origWalker = document.createTreeWalker( this.editorEl, NodeFilter.SHOW_ELEMENT ); const cloneWalker = document.createTreeWalker( clone, NodeFilter.SHOW_ELEMENT ); const importantProps = this.getComputedImportantProps(); while (origWalker.nextNode() && cloneWalker.nextNode()) { const origEl = origWalker.currentNode as HTMLElement; const cloneEl = cloneWalker.currentNode as HTMLElement; const computed = window.getComputedStyle(origEl); const cssText = importantProps .map(prop => `${prop}:${computed.getPropertyValue(prop)}`) .join(';'); if (cssText.trim()) { cloneEl.setAttribute('style', cssText); } } } else { clone.querySelectorAll('[style]').forEach(el => el.removeAttribute('style') ); } if (caretRange && sel && this.editorEl.contains(caretRange.startContainer)) { sel.removeAllRanges(); sel.addRange(caretRange); } this.content = clone.innerHTML; if (this.previewEl) this.previewEl.innerHTML = this.content; this.dispatchEvent( new CustomEvent('nile-change', { detail: { content: this.content }, bubbles: true, composed: true, }) ); } } declare global { interface HTMLElementTagNameMap { 'nile-rich-text-editor': NileRichTextEditor; } }