import { LitElement, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; @customElement('nile-rte-image') export class NileRteImage extends LitElement { protected createRenderRoot() { return this; } @property({ attribute: false }) editorEl!: HTMLElement; @property({ type: Boolean, reflect: true, attribute: true }) disabled = false; @property({ type: String, reflect: true, attribute: true }) label = 'Image'; @property({ type: String, reflect: true, attribute: true }) placeholder = 'Paste image link here'; @property({ type: String, reflect: true, attribute: true }) altPlaceholder = 'Describe the image'; @property({ type: String, reflect: true, attribute: true }) captionPlaceholder = 'Add a caption (optional)'; @property({ type: Number, reflect: true, attribute: 'maxfilesize' }) maxFileSize = 2 * 1024 * 1024; @property({ type: Boolean, reflect: true, attribute: 'uploadfromcomputer' }) uploadFromComputer = false; @state() private srcValue = ''; @state() private altValue = ''; @state() private captionValue = ''; @state() private hasActiveImage = false; @state() private uploadedFileName = ''; @state() private errorMessage = ''; @state() private popoverStyle = ''; private selectionRange: Range | null = null; private activeFigure: HTMLElement | null = null; private ignoreBlur = false; private isApplying = false; private selectedFigure: HTMLElement | null = null; private resizeOverlay: HTMLElement | null = null; private dragStartX = 0; private dragStartWidth = 0; private dragDir = ''; private isDragging = false; private trackingRaf = 0; connectedCallback() { super.connectedCallback(); this.injectCss(` nile-popover.rte-image-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); } .image-popup-wrap { display: flex; flex-direction: column; gap: 12px; padding: 12px; } .image-popup-wrap nile-input { width: 100%; } .image-popup-wrap .image-upload-row { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; } .image-popup-wrap .image-upload-row nile-button { flex-shrink: 0; } .image-popup-wrap .image-upload-filename { font-size: 12px; color: var(--nile-colors-neutral-600, var(--ng-colors-text-secondary)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .image-popup-wrap .image-error { font-size: 12px; color: var(--nile-colors-error-600, var(--ng-colors-text-error-primary-600)); } .image-popup-wrap .image-divider-row { display: flex; align-items: center; gap: 8px; color: var(--nile-colors-neutral-500, var(--ng-colors-text-secondary)); font-size: 12px; } .image-popup-wrap .image-divider-row::before, .image-popup-wrap .image-divider-row::after { content: ''; flex: 1; height: 1px; background: var(--nile-colors-neutral-400, var(--ng-colors-border-neutral)); } .image-popup-wrap .image-actions-row { display: flex; justify-content: flex-end; gap: 8px; width: 100%; } nile-button.rte-image-button::part(base) { border: none; height: 32px; width: 32px; } nile-rte-image input[type='file'] { display: none; } .rte-image-resize-overlay { position: fixed; z-index: 999; pointer-events: none; border: 2px solid var(--nile-colors-primary-500, var(--ng-componentcolors-utility-blue-500)); border-radius: var(--nile-radius-radius-lg, var(--ng-radius-md)); box-sizing: border-box; } .rte-image-resize-overlay .rte-resize-handle { position: absolute; width: 12px; height: 12px; background: var(--nile-colors-white-base, #fff); border: 2px solid var(--nile-colors-primary-500, var(--ng-componentcolors-utility-blue-500)); border-radius: 50%; pointer-events: auto; box-sizing: border-box; } .rte-image-resize-overlay .rte-resize-handle[data-dir='nw'] { top: -7px; left: -7px; cursor: nwse-resize; } .rte-image-resize-overlay .rte-resize-handle[data-dir='ne'] { top: -7px; right: -7px; cursor: nesw-resize; } .rte-image-resize-overlay .rte-resize-handle[data-dir='sw'] { bottom: -7px; left: -7px; cursor: nesw-resize; } .rte-image-resize-overlay .rte-resize-handle[data-dir='se'] { bottom: -7px; right: -7px; cursor: nwse-resize; } .rte-image-resize-overlay .rte-image-align-bar { position: absolute; top: -44px; left: 50%; transform: translateX(-50%); display: flex; gap: 2px; padding: 4px; background: var(--nile-colors-white-base, #fff); border: 1px solid var(--nile-colors-neutral-400, var(--ng-colors-border-neutral)); border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); pointer-events: auto; } .rte-image-resize-overlay .rte-image-align-bar button { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; background: transparent; border-radius: 6px; cursor: pointer; padding: 0; } .rte-image-resize-overlay .rte-image-align-bar button:hover { background: var(--nile-colors-neutral-200, var(--ng-componentcolors-utility-gray-200)); } .rte-image-resize-overlay .rte-image-align-bar button[data-active] { background: var(--nile-colors-primary-400, var(--ng-componentcolors-utility-blue-400)); } .rte-image-resize-overlay .rte-image-align-bar button nile-icon { pointer-events: none; } .rte-image-resize-overlay .rte-resize-size-label { position: absolute; bottom: -26px; right: 0; background: var(--nile-colors-dark-900, #101828); color: #fff; font-size: 11px; line-height: 1; padding: 4px 6px; border-radius: 4px; white-space: nowrap; pointer-events: none; } `); } private injectCss(cssText: string) { if (this.querySelector('style[data-rte-image-style]')) return; const style = document.createElement('style'); style.setAttribute('data-rte-image-style', 'true'); style.textContent = cssText; this.insertBefore(style, this.firstChild); } protected updated(changed: Map) { if (changed.has('editorEl')) { const prev = changed.get('editorEl') as HTMLElement | undefined; prev?.removeEventListener('click', this.onEditorImageClick); prev?.removeEventListener('dblclick', this.onEditorImageDblClick); prev?.removeEventListener('input', this.onEditorInput); prev?.removeEventListener('paste', this.onEditorImagePaste); this.editorEl?.addEventListener('click', this.onEditorImageClick); this.editorEl?.addEventListener('dblclick', this.onEditorImageDblClick); this.editorEl?.addEventListener('input', this.onEditorInput); this.editorEl?.addEventListener('paste', this.onEditorImagePaste); } if (changed.has('disabled') && this.disabled) { this.deselectImage(); } } disconnectedCallback(): void { this.editorEl?.removeEventListener('click', this.onEditorImageClick); this.editorEl?.removeEventListener('dblclick', this.onEditorImageDblClick); this.editorEl?.removeEventListener('input', this.onEditorInput); this.editorEl?.removeEventListener('paste', this.onEditorImagePaste); window.removeEventListener('scroll', this.onScroll, true); this.deselectImage(); super.disconnectedCallback(); } private onEditorInput = () => { // content edits can remove or reflow the figure if (this.selectedFigure && !this.selectedFigure.isConnected) { this.deselectImage(); } else { this.updateOverlayPosition(); } }; private onEditorImagePaste = (e: ClipboardEvent) => { if (this.disabled) return; const items = Array.from(e.clipboardData?.items ?? []); const imageFiles = items .filter(i => i.kind === 'file' && i.type.startsWith('image/')) .map(i => i.getAsFile()) .filter((f): f is File => !!f); if (!imageFiles.length) return; e.preventDefault(); e.stopPropagation(); for (const file of imageFiles) { if (this.maxFileSize > 0 && file.size > this.maxFileSize) { console.warn( `nile-rte-image: pasted image "${file.name}" skipped — exceeds the ${this.maxFileSize} byte limit` ); continue; } const reader = new FileReader(); reader.onload = () => { this.insertPastedImage( String(reader.result || ''), file.name.replace(/\.[^.]+$/, '') ); }; reader.readAsDataURL(file); } }; private insertPastedImage(src: string, alt: string) { if (!src.startsWith('data:image/')) return; let range: Range; const sel = document.getSelection(); if ( sel && sel.rangeCount > 0 && this.editorEl.contains(sel.getRangeAt(0).commonAncestorContainer) ) { range = sel.getRangeAt(0).cloneRange(); } else { range = document.createRange(); range.selectNodeContents(this.editorEl); range.collapse(false); } const figure = this.buildFigure(src, alt); this.insertFigureAt(range, figure); this.emit(src); } private isSafeSrc(src: string): boolean { return /^(https?:\/\/|data:image\/|blob:|\/)/i.test(src); } private normalizeSrc(src: string): string { if (this.isSafeSrc(src)) return src; if (/^[a-z][a-z0-9+.-]*:/i.test(src)) return ''; return `https://${src}`; } 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(anchorRect?: DOMRect | null) { const rect = anchorRect ?? this.getSelectionRect(); if (!rect) return; const half = 170; const centerX = Math.min( Math.max(rect.left + rect.width / 2, half + 8), window.innerWidth - half - 8 ); this.popoverStyle = ` position: fixed; top: ${rect.bottom + 8}px; left: ${centerX}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.srcValue = ''; this.altValue = ''; this.captionValue = ''; this.uploadedFileName = ''; this.errorMessage = ''; this.selectionRange = null; this.activeFigure = null; this.hasActiveImage = false; this.popoverStyle = ''; } private onEditorImageClick = (e: MouseEvent) => { if (this.disabled) return; const target = e.target as HTMLElement; const figure = target.closest?.('figure.rte-image') as HTMLElement | null; if (!figure || !this.editorEl.contains(figure)) { this.deselectImage(); return; } e.preventDefault(); this.selectImage(figure); }; private onEditorImageDblClick = (e: MouseEvent) => { if (this.disabled) return; const target = e.target as HTMLElement; const figure = target.closest?.('figure.rte-image') as HTMLElement | null; if (!figure || !this.editorEl.contains(figure)) return; e.preventDefault(); this.openEditFor(figure); }; private openEditFor(figure: HTMLElement, anchorRect?: DOMRect | null) { const range = document.createRange(); range.selectNode(figure); this.selectionRange = range; this.activeFigure = figure; this.hasActiveImage = true; const img = figure.querySelector('img'); const figcaption = figure.querySelector('figcaption'); this.srcValue = img?.getAttribute('src') || ''; this.altValue = img?.getAttribute('alt') || ''; this.captionValue = figcaption?.textContent || ''; this.uploadedFileName = ''; this.errorMessage = ''; this.positionPopover(anchorRect ?? figure.getBoundingClientRect()); this.openPopover(); } private getToolbarButtonRect(): DOMRect | null { const btn = this.querySelector('nile-button.rte-image-button'); return btn ? btn.getBoundingClientRect() : null; } // ---------- resize ---------- private selectImage(figure: HTMLElement) { if (this.selectedFigure === figure) { this.updateOverlayPosition(); return; } this.deselectImage(); this.selectedFigure = figure; const img = figure.querySelector('img'); if (img && !img.complete) { img.addEventListener('load', this.onSelectedImageLoad, { once: true }); } this.buildOverlay(); this.syncAlignBar(); this.updateOverlayPosition(); this.startOverlayTracking(); document.addEventListener('mousedown', this.onDocMousedown, true); document.addEventListener('keydown', this.onDocKeydown, true); } private deselectImage() { if (!this.selectedFigure && !this.resizeOverlay) return; this.selectedFigure = null; this.resizeOverlay?.remove(); this.resizeOverlay = null; this.isDragging = false; this.stopOverlayTracking(); document.removeEventListener('mousedown', this.onDocMousedown, true); document.removeEventListener('keydown', this.onDocKeydown, true); document.removeEventListener('mousemove', this.onDragMove); document.removeEventListener('mouseup', this.onDragEnd); } private onDocKeydown = (e: KeyboardEvent) => { if (!this.selectedFigure) return; if (e.key === 'Escape') { this.deselectImage(); return; } if (e.key !== 'Backspace' && e.key !== 'Delete') return; // don't hijack deletions typed into inputs (e.g. the edit popover) const target = e.target as HTMLElement; if (target?.closest?.('input, textarea, nile-input, nile-popover')) return; e.preventDefault(); e.stopPropagation(); const figure = this.selectedFigure; this.deselectImage(); const parent = figure.parentNode; figure.remove(); if (parent instanceof HTMLElement && !parent.hasChildNodes()) { parent.innerHTML = '
'; } this.emit(''); }; private onSelectedImageLoad = () => { this.updateOverlayPosition(); }; // re-sync the overlay to the image every frame while selected — this keeps // it glued to the image through scrolling (any container), layout shifts, // and image loads without needing to enumerate those events private startOverlayTracking() { this.stopOverlayTracking(); const tick = () => { if (!this.selectedFigure || !this.resizeOverlay) return; if (!this.selectedFigure.isConnected) { this.deselectImage(); return; } this.updateOverlayPosition(); this.trackingRaf = requestAnimationFrame(tick); }; this.trackingRaf = requestAnimationFrame(tick); } private stopOverlayTracking() { if (this.trackingRaf !== 0) { cancelAnimationFrame(this.trackingRaf); this.trackingRaf = 0; } } private onDocMousedown = (e: MouseEvent) => { const target = e.target as HTMLElement; if (this.resizeOverlay?.contains(target)) return; if (this.selectedFigure?.contains(target)) return; // clicks on our own UI (toolbar image button, popover) must not clear the // selection, otherwise pressing the toolbar button while an image is // selected would drop into insert mode instead of edit mode if (this.contains(target)) return; this.deselectImage(); }; private buildOverlay() { const overlay = document.createElement('div'); overlay.className = 'rte-image-resize-overlay'; for (const dir of ['nw', 'ne', 'sw', 'se']) { const handle = document.createElement('div'); handle.className = 'rte-resize-handle'; handle.dataset.dir = dir; handle.addEventListener('mousedown', this.onHandleMousedown); overlay.appendChild(handle); } const label = document.createElement('span'); label.className = 'rte-resize-size-label'; overlay.appendChild(label); const alignBar = document.createElement('div'); alignBar.className = 'rte-image-align-bar'; const alignments: Array<{ dir: string; icon: string; title: string }> = [ { dir: 'left', icon: 'format_align_left', title: 'Align left' }, { dir: 'center', icon: 'format_align_middle', title: 'Align center' }, { dir: 'right', icon: 'format_align_right', title: 'Align right' }, { dir: 'justify', icon: 'format_align_justify', title: 'Full width' }, ]; for (const { dir, icon, title } of alignments) { const btn = document.createElement('button'); btn.type = 'button'; btn.dataset.dir = dir; btn.title = title; btn.setAttribute('aria-label', title); btn.innerHTML = ``; btn.addEventListener('mousedown', e => e.preventDefault()); btn.addEventListener('click', this.onAlignClick); alignBar.appendChild(btn); } overlay.appendChild(alignBar); // append to : a transformed/filtered ancestor (drawer, modal, // animated panel) would otherwise become the containing block for // position:fixed and the overlay would render far from the image document.body.appendChild(overlay); this.resizeOverlay = overlay; } private updateOverlayPosition() { if (!this.resizeOverlay || !this.selectedFigure) return; const img = this.selectedFigure.querySelector('img'); if (!img) { this.deselectImage(); return; } const rect = img.getBoundingClientRect(); this.resizeOverlay.style.top = `${rect.top}px`; this.resizeOverlay.style.left = `${rect.left}px`; this.resizeOverlay.style.width = `${rect.width}px`; this.resizeOverlay.style.height = `${rect.height}px`; // keep the align bar reachable: above the image by default, flipped to // just inside the top edge when the image touches the viewport top const bar = this.resizeOverlay.querySelector( '.rte-image-align-bar' ) as HTMLElement | null; if (bar) { if (rect.top < 56) { bar.style.top = '8px'; } else { bar.style.top = '-44px'; } } const label = this.resizeOverlay.querySelector( '.rte-resize-size-label' ) as HTMLElement | null; if (label) { label.textContent = `${Math.round(rect.width)} × ${Math.round(rect.height)}`; label.style.display = this.isDragging ? '' : 'none'; } } private onAlignClick = (e: Event) => { if (!this.selectedFigure) return; const dir = (e.currentTarget as HTMLElement).dataset.dir || 'left'; if (dir === 'left') { this.selectedFigure.removeAttribute('data-align'); } else { this.selectedFigure.setAttribute('data-align', dir); } this.syncAlignBar(); this.updateOverlayPosition(); const img = this.selectedFigure.querySelector('img'); this.emit(img?.getAttribute('src') || ''); }; private syncAlignBar() { if (!this.resizeOverlay || !this.selectedFigure) return; const current = this.selectedFigure.getAttribute('data-align') || 'left'; this.resizeOverlay .querySelectorAll('.rte-image-align-bar button') .forEach(btn => { btn.toggleAttribute('data-active', btn.dataset.dir === current); }); } private onHandleMousedown = (e: MouseEvent) => { if (this.disabled || !this.selectedFigure) return; e.preventDefault(); e.stopPropagation(); const img = this.selectedFigure.querySelector('img'); if (!img) return; // a full-width image can't be hand-resized — dragging implies a custom // width, so drop back to default alignment first if (this.selectedFigure.getAttribute('data-align') === 'justify') { this.selectedFigure.removeAttribute('data-align'); this.syncAlignBar(); } this.isDragging = true; this.dragDir = (e.currentTarget as HTMLElement).dataset.dir || 'se'; this.dragStartX = e.clientX; this.dragStartWidth = img.getBoundingClientRect().width; document.addEventListener('mousemove', this.onDragMove); document.addEventListener('mouseup', this.onDragEnd); }; private onDragMove = (e: MouseEvent) => { if (!this.isDragging || !this.selectedFigure) return; const img = this.selectedFigure.querySelector('img'); if (!img) return; const dx = e.clientX - this.dragStartX; const growsRight = this.dragDir === 'ne' || this.dragDir === 'se'; let width = this.dragStartWidth + (growsRight ? dx : -dx); const maxWidth = this.getEditorContentWidth(); width = Math.max(48, Math.min(width, maxWidth)); img.setAttribute('width', String(Math.round(width))); this.updateOverlayPosition(); }; private onDragEnd = () => { if (!this.isDragging) return; this.isDragging = false; document.removeEventListener('mousemove', this.onDragMove); document.removeEventListener('mouseup', this.onDragEnd); this.updateOverlayPosition(); const img = this.selectedFigure?.querySelector('img'); this.dispatchEvent( new CustomEvent('nile-image-changed', { detail: { src: img?.getAttribute('src') || '', alt: img?.getAttribute('alt') || '', caption: this.selectedFigure?.querySelector('figcaption')?.textContent || '', width: img ? Number(img.getAttribute('width')) || null : null, }, bubbles: true, composed: true, }) ); }; private getEditorContentWidth(): number { const style = getComputedStyle(this.editorEl); return ( this.editorEl.clientWidth - parseFloat(style.paddingLeft || '0') - parseFloat(style.paddingRight || '0') || Infinity ); } private onOpen = () => { if (this.disabled) return; if (this.selectedFigure) { this.openEditFor(this.selectedFigure, this.getToolbarButtonRect()); return; } const sel = document.getSelection(); if ( sel && sel.rangeCount > 0 && this.editorEl.contains(sel.getRangeAt(0).commonAncestorContainer) ) { this.selectionRange = sel.getRangeAt(0).cloneRange(); } else { const range = document.createRange(); range.selectNodeContents(this.editorEl); range.collapse(false); this.selectionRange = range; } this.activeFigure = null; this.hasActiveImage = false; this.srcValue = ''; this.altValue = ''; this.captionValue = ''; this.uploadedFileName = ''; this.errorMessage = ''; this.positionPopover(this.getToolbarButtonRect()); this.openPopover(); requestAnimationFrame(() => { const input = this.querySelector('nile-input'); (input 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.applyImage(); } 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 onSrcInput = (e: CustomEvent) => { this.srcValue = e.detail.value; this.uploadedFileName = ''; this.errorMessage = ''; }; private onAltInput = (e: CustomEvent) => { this.altValue = e.detail.value; }; private onCaptionInput = (e: CustomEvent) => { this.captionValue = e.detail.value; }; private onPopoverMousedown = () => { this.ignoreBlur = true; }; private onPopoverMouseup = () => { this.ignoreBlur = false; }; private onToolbarButtonMousedown = (e: Event) => { e.preventDefault(); }; private onUploadClick = () => { // keep the popover open while the OS file dialog steals focus this.ignoreBlur = true; const fileInput = this.querySelector( 'input[type="file"]' ) as HTMLInputElement | null; fileInput?.click(); window.addEventListener( 'focus', () => { setTimeout(() => { this.ignoreBlur = false; }, 200); }, { once: true } ); }; private onFileChange = (e: Event) => { const input = e.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file) return; if (!file.type.startsWith('image/')) { this.errorMessage = 'Only image files are supported'; return; } if (this.maxFileSize > 0 && file.size > this.maxFileSize) { const mb = (this.maxFileSize / (1024 * 1024)).toFixed(1); this.errorMessage = `File is too large. Maximum size is ${mb} MB`; return; } const reader = new FileReader(); reader.onload = () => { this.srcValue = String(reader.result || ''); this.uploadedFileName = file.name; this.errorMessage = ''; if (!this.altValue) { this.altValue = file.name.replace(/\.[^.]+$/, ''); } }; reader.onerror = () => { this.errorMessage = 'Could not read the selected file'; }; reader.readAsDataURL(file); }; private onApplyClick = () => { this.isApplying = true; this.applyImage(); this.isApplying = false; this.ignoreBlur = false; }; private onRemoveClick = () => { this.isApplying = true; this.removeImage(); this.isApplying = false; this.ignoreBlur = false; }; private buildFigure(src: string, alt = '', caption = ''): HTMLElement { const figure = document.createElement('figure'); figure.className = 'rte-image'; figure.setAttribute('contenteditable', 'false'); const img = document.createElement('img'); img.src = src; if (alt.trim()) img.alt = alt.trim(); figure.appendChild(img); if (caption.trim()) { const figcaption = document.createElement('figcaption'); figcaption.textContent = caption.trim(); figure.appendChild(figcaption); } return figure; } private insertFigureAt(insertRange: Range, figure: HTMLElement) { const sel = document.getSelection(); if (!sel) return; // a
is not valid inside

/, so place it after the // block that holds the caret instead of splitting it at the range let block: HTMLElement | null = null; let n: Node | null = insertRange.startContainer; while (n && n !== this.editorEl) { if ( n instanceof HTMLElement && /^(P|H[1-6]|UL|OL|BLOCKQUOTE|PRE|TABLE|FIGURE|DIV)$/.test(n.tagName) ) { block = n; break; } n = n.parentNode; } // an empty paragraph (the default '


' of a fresh editor) would // otherwise remain above the image as a blank gap — take its place instead const isEmptyBlock = block && /^(P|H[1-6])$/.test(block.tagName) && (block.textContent ?? '').replace(/\u200B/g, '').trim() === '' && !block.querySelector('img'); if (block?.parentNode) { if (isEmptyBlock) { block.parentNode.insertBefore(figure, block); block.remove(); } else { block.parentNode.insertBefore(figure, block.nextSibling); } } else { this.editorEl.appendChild(figure); } // keep a paragraph after the image so typing can continue below it let after = figure.nextElementSibling; if (!after) { const p = document.createElement('p'); p.innerHTML = '
'; figure.parentNode?.insertBefore(p, figure.nextSibling); after = p; } const range = document.createRange(); range.setStart(after, 0); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } private applyImage() { const raw = this.srcValue.trim(); if (!raw) { if (this.uploadFromComputer) { this.errorMessage = 'Enter an image link or upload a file'; } return; } const src = this.normalizeSrc(raw); if (!src) { this.errorMessage = 'Enter a valid image link'; return; } if (this.activeFigure) { const img = this.activeFigure.querySelector('img'); if (img) { img.src = src; if (this.altValue.trim()) { img.alt = this.altValue.trim(); } else { img.removeAttribute('alt'); } } let figcaption = this.activeFigure.querySelector('figcaption'); if (this.captionValue.trim()) { if (!figcaption) { figcaption = document.createElement('figcaption'); this.activeFigure.appendChild(figcaption); } figcaption.textContent = this.captionValue.trim(); } else { figcaption?.remove(); } this.emit(src); this.closePopover(); requestAnimationFrame(() => this.updateOverlayPosition()); return; } if (!(this.selectionRange instanceof Range)) return; this.editorEl.focus(); const sel = document.getSelection(); if (!sel) return; sel.removeAllRanges(); sel.addRange(this.selectionRange); const figure = this.buildFigure(src, this.altValue, this.captionValue); this.insertFigureAt(this.selectionRange, figure); this.emit(src); this.closePopover(); } private removeImage() { if (!this.activeFigure) return; this.deselectImage(); const parent = this.activeFigure.parentNode; this.activeFigure.remove(); if (parent instanceof HTMLElement && !parent.hasChildNodes()) { parent.innerHTML = '
'; } this.emit(''); this.closePopover(); } private emit(src: string) { this.dispatchEvent( new CustomEvent('nile-image-changed', { detail: { src, alt: this.altValue.trim(), caption: this.captionValue.trim(), }, 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`
${this.uploadFromComputer ? html`
or
Upload from computer ${this.uploadedFileName ? html`${this.uploadedFileName}` : null}
` : null} ${this.errorMessage ? html`${this.errorMessage}` : null}
${this.hasActiveImage ? html` Remove ` : null} ${this.hasActiveImage ? 'Update' : 'Insert'}
`; } } declare global { interface HTMLElementTagNameMap { 'nile-rte-image': NileRteImage; } }