import { LitElement, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; @customElement('nile-rte-link') export class NileRteLink extends LitElement { protected createRenderRoot() { return this; } @property({ attribute: false }) editorEl!: HTMLElement; @property({ type: Boolean, reflect: true, attribute: true }) newTab = false; @property({ type: Boolean, reflect: true, attribute: true }) disabled = false; @property({ type: Boolean, reflect: true, attribute: true }) showTextSupport = false; @property({ type: String, reflect: true, attribute: true }) placeholder = 'Type or paste link here'; @property({ type: String, reflect: true, attribute: true }) textPlaceholder = 'Display text'; @property({ type: String, reflect: true, attribute: true }) label = 'Link'; @state() private linkValue = ''; @state() private textValue = ''; @state() private hasActiveLink = false; @state() private popoverStyle = ''; private selectionRange: Range | null = null; private activeAnchor: HTMLAnchorElement | null = null; private ignoreBlur = false; private isApplying = false; connectedCallback() { super.connectedCallback(); this.injectCss(` nile-popover.rte-link-popover::part(popover) { min-width: 340px; max-width: 420px; background: white; border: 1px solid var(--nile-colors-neutral-400, var(--ng-componentcolors-utility-gray-400)); border-radius: 8px; padding: 0; gap: 0; overflow: hidden; height: auto; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); } .link-popup-wrap { display: flex; flex-direction: column; gap: 12px; padding: 12px; } .link-popup-wrap nile-input { width: 100%; } .link-popup-wrap .link-input-row { width: 100%; } .link-popup-wrap .link-actions-row { display: flex; align-items: flex-end; gap: 4px; width: 100%; } .link-popup-wrap .link-actions-row nile-input { flex: 1; min-width: 0; } nile-button.rte-link-button::part(base) { border: none; height: 32px; width: 32px; } nile-button.rte-edit-button::part(base) { border: none; height: 32px; width: 32px; flex-shrink: 0; } `); } private injectCss(cssText: string) { if (this.querySelector('style[data-rte-link-style]')) return; const style = document.createElement('style'); style.setAttribute('data-rte-link-style', 'true'); style.textContent = cssText; this.insertBefore(style, this.firstChild); } private normalizeUrl(url: string) { if (/^(https?:|mailto:|tel:)/i.test(url)) return url; return `https://${url}`; } private displayUrl(url: string): string { return url; } private getSelectionRect(): DOMRect | null { if (!this.selectionRange) return null; let rect = this.selectionRange.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) { const span = document.createElement('span'); span.textContent = '\u200b'; const tempRange = this.selectionRange.cloneRange(); tempRange.insertNode(span); rect = span.getBoundingClientRect(); span.remove(); } return rect; } private positionPopover() { const rect = this.getSelectionRect(); if (!rect) return; this.popoverStyle = ` position: fixed; top: ${rect.bottom + 8}px; left: ${rect.left + rect.width / 2}px; transform: translateX(-50%); z-index: 1000; `; } private onScroll = () => { this.closePopover(); }; private openPopover() { requestAnimationFrame(() => { const pop = this.querySelector('nile-popover') as any; pop && (pop.isShow = true); window.addEventListener('scroll', this.onScroll, true); }); } private closePopover() { const pop = this.querySelector('nile-popover') as any; pop && (pop.isShow = false); window.removeEventListener('scroll', this.onScroll, true); this.linkValue = ''; this.textValue = ''; this.selectionRange = null; this.activeAnchor = null; this.hasActiveLink = false; this.popoverStyle = ''; } private unwrapLink(a: HTMLAnchorElement) { while (a.firstChild) a.parentNode?.insertBefore(a.firstChild, a); a.remove(); } private onOpen = () => { if (this.disabled) return; const sel = document.getSelection(); if (!sel || sel.rangeCount === 0) return; if (!this.editorEl.contains(sel.getRangeAt(0).commonAncestorContainer)) return; this.selectionRange = sel.getRangeAt(0).cloneRange(); this.activeAnchor = null; this.hasActiveLink = false; let n: Node | null = this.selectionRange.startContainer; while (n && n !== this.editorEl) { if (n instanceof HTMLAnchorElement) { this.activeAnchor = n; this.hasActiveLink = true; break; } n = n.parentNode; } this.linkValue = this.activeAnchor ? this.displayUrl(this.activeAnchor.href) : ''; if (this.showTextSupport) { if (this.activeAnchor) { this.textValue = this.activeAnchor.textContent || ''; } else if (!this.selectionRange.collapsed) { this.textValue = this.selectionRange.toString(); } else { this.textValue = ''; } } this.positionPopover(); this.openPopover(); requestAnimationFrame(() => { const inputs = this.querySelectorAll('nile-input'); const target = inputs.length > 1 ? inputs[1] : inputs[0]; (target as any)?.focus(); }); } private onInputKeydown = (e: KeyboardEvent) => { if (e.key !== 'Escape' && e.key !== 'Enter') return; e.preventDefault(); e.stopPropagation(); if (e.key === 'Escape') { this.closePopover(); return; } this.isApplying = true; try { this.applyLink(); } finally { this.isApplying = false; } }; private onInputBlur = () => { if (this.ignoreBlur || this.isApplying) return; requestAnimationFrame(() => { if (this.ignoreBlur || this.isApplying) return; const pop = this.querySelector('nile-popover'); if (pop?.matches(':focus-within')) return; this.closePopover(); }); }; private onLinkInput = (e: CustomEvent) => { this.linkValue = e.detail.value; }; private onTextInput = (e: CustomEvent) => { this.textValue = e.detail.value; }; private onPopoverMousedown = () => { this.ignoreBlur = true; }; private onPopoverMouseup = () => { this.ignoreBlur = false; }; private onToolbarButtonMousedown = (e: Event) => { e.preventDefault(); }; private onActionMousedown = (e: Event) => { e.preventDefault(); this.ignoreBlur = true; }; private onApplyClick = () => { this.isApplying = true; this.applyLink(); this.isApplying = false; this.ignoreBlur = false; }; private onUnlinkClick = () => { this.isApplying = true; this.onRemove(); this.isApplying = false; this.ignoreBlur = false; }; private applyLink() { if (!(this.selectionRange instanceof Range)) return; let url = this.linkValue.trim(); if (!url) return; if (/^javascript:/i.test(url)) return; if (!/^(https?:\/\/|mailto:|tel:|\/)/i.test(url)) { url = `https://${url}`; } this.editorEl.focus(); const sel = document.getSelection(); if (!sel) return; sel.removeAllRanges(); sel.addRange(this.selectionRange); const displayText = this.showTextSupport && this.textValue.trim() ? this.textValue.trim() : ''; if (this.activeAnchor) { this.activeAnchor.href = url; if (displayText) { this.activeAnchor.textContent = displayText; } if (this.newTab) { this.activeAnchor.target = '_blank'; this.activeAnchor.rel = 'noopener noreferrer'; } else { this.activeAnchor.removeAttribute('target'); this.activeAnchor.removeAttribute('rel'); } } else { const link = document.createElement('a'); link.href = url; if (this.newTab) { link.target = '_blank'; link.rel = 'noopener noreferrer'; } else { link.removeAttribute('target'); link.removeAttribute('rel'); } if (displayText) { this.selectionRange.deleteContents(); link.textContent = displayText; this.selectionRange.insertNode(link); } else if (this.selectionRange.collapsed) { link.textContent = url; this.selectionRange.insertNode(link); } else { try { this.selectionRange.surroundContents(link); } catch { const frag = this.selectionRange.extractContents(); link.appendChild(frag); this.selectionRange.insertNode(link); } } } this.emit(url); this.closePopover(); } private onRemove() { if (!this.activeAnchor) return; this.unwrapLink(this.activeAnchor); this.emit(''); this.closePopover(); } private emit(href: string) { this.dispatchEvent( new CustomEvent('nile-link-changed', { detail: { href }, bubbles: true, composed: true, }) ); } render() { const iconColor = this.disabled ? 'var(--nile-colors-neutral-500, var(--ng-colors-fg-disabled-subtle))' : 'var(--nile-colors-dark-900, var(--ng-colors-text-primary-900))'; return html` `; } } declare global { interface HTMLElementTagNameMap { 'nile-rte-link': NileRteLink; } }