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;
}
}