angular-rich-text-editor
Version:
A lightweight, configurable rich-text editor component for Angular applications.
1,191 lines (1,171 loc) • 44.6 kB
JavaScript
import * as i0 from '@angular/core';
import { InjectionToken, Injectable, Inject, forwardRef, Component, Optional, ViewChild, Input, NgModule } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { NgControl, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
import * as i2 from '@angular/common';
import { CommonModule } from '@angular/common';
// src/lib/paths.ts
// Default paths
const DEFAULT_RICHTEXTEDITOR_ASSETS_PATH = 'assets/richtexteditor';
// Injection token for configuration
const RICHTEXTEDITOR_ASSETS_PATH = new InjectionToken('RICHTEXTEDITOR_ASSETS_PATH', {
providedIn: 'root',
factory: () => DEFAULT_RICHTEXTEDITOR_ASSETS_PATH
});
class RichTextEditorService {
assetsPath;
currentEditor = null; // Reference to the current RTE component
contentSubject = new BehaviorSubject('');
// Observable for content changes
content$ = this.contentSubject.asObservable();
constructor(assetsPath) {
this.assetsPath = assetsPath;
}
getContentCssUrl() {
return `${this.assetsPath}/runtime/richtexteditor_content.css`;
}
getPreviewCssUrl() {
return `${this.assetsPath}/runtime/richtexteditor_preview.css`;
}
getPreviewScriptUrl() {
return `${this.assetsPath}/runtime/richtexteditor_preview.js`;
}
// Simple editor management
setCurrentEditor(component) {
this.currentEditor = component;
}
clearCurrentEditor() {
this.currentEditor = null;
this.contentSubject.next('');
}
// Content manipulation methods
insertContentAtCursor(content) {
if (!this.currentEditor) {
console.warn('[RTE Service] No editor is currently active');
return false;
}
try {
this.currentEditor.insertContentAtCursor(content);
// Update the observable after insertion
const newContent = this.getContent();
this.contentSubject.next(newContent);
return true;
}
catch (error) {
console.error('[RTE Service] Failed to insert content:', error);
return false;
}
}
/**
* Get HTML content from current editor
* @returns HTML string (empty string if no content/editor)
*/
getContent() {
if (!this.currentEditor?.editorInstance) {
console.warn('[RTE Service] No active editor found');
return '';
}
try {
const htmlContent = this.currentEditor.editorInstance.getHTMLCode();
// Handle null/undefined cases
if (htmlContent === null || htmlContent === undefined) {
return this.getContentFallback();
}
return htmlContent;
}
catch (error) {
console.error('[RTE Service] Failed to get content:', error);
return this.getContentFallback();
}
}
/**
* Fallback method to retrieve content
*/
getContentFallback() {
try {
// Try to get from iframe directly
const iframe = this.currentEditor?.editorContainer?.nativeElement?.querySelector('iframe');
if (iframe?.contentDocument?.body) {
return iframe.contentDocument.body.innerHTML || '';
}
// Try to get from component's value
if (this.currentEditor?.value) {
return this.currentEditor.value;
}
return '';
}
catch (error) {
console.error('[RTE Service] Fallback retrieval failed:', error);
return '';
}
}
/**
* Set HTML content for current editor
*/
setContent(content) {
if (!this.currentEditor?.editorInstance) {
console.warn('[RTE Service] No active editor found');
return false;
}
try {
this.currentEditor.editorInstance.setHTMLCode(content);
// Ensure component state is synced
if (this.currentEditor.value !== content) {
this.currentEditor.value = content;
}
// Trigger change event if needed
if (this.currentEditor.onChange) {
this.currentEditor.onChange(content);
}
// Update observable
this.contentSubject.next(content);
return true;
}
catch (error) {
console.error('[RTE Service] Failed to set content:', error);
return false;
}
}
/**
* Clear editor content
*/
clearContent() {
return this.setContent('<p><br></p>');
}
/**
* Focus current editor
*/
focus() {
if (!this.currentEditor) {
console.warn('[RTE Service] No active editor found');
return false;
}
try {
// Try editor's focus method first
if (this.currentEditor.editorInstance?.focus) {
this.currentEditor.editorInstance.focus();
return true;
}
// Fallback to iframe focus
const iframe = this.currentEditor.editorContainer?.nativeElement?.querySelector('iframe');
if (iframe?.contentDocument?.body) {
iframe.contentDocument.body.focus();
return true;
}
return false;
}
catch (error) {
console.error('[RTE Service] Failed to focus editor:', error);
return false;
}
}
/**
* Execute command on the editor
*/
executeCommand(command, value) {
if (!this.currentEditor?.editorInstance) {
console.warn('[RTE Service] No active editor found');
return false;
}
try {
// Try editor's execCommand if available
if (typeof this.currentEditor.editorInstance.execCommand === 'function') {
this.currentEditor.editorInstance.execCommand(command, false, value);
return true;
}
// Fallback to iframe execCommand
const iframe = this.currentEditor.editorContainer?.nativeElement?.querySelector('iframe');
if (iframe?.contentDocument) {
iframe.contentDocument.execCommand(command, false, value);
return true;
}
return false;
}
catch (error) {
console.error('[RTE Service] Failed to execute command:', error);
return false;
}
}
/**
* Get selected text from editor
*/
getSelectedText() {
if (!this.currentEditor?.editorContainer) {
return '';
}
try {
const iframe = this.currentEditor.editorContainer.nativeElement.querySelector('iframe');
if (iframe?.contentWindow) {
const selection = iframe.contentWindow.getSelection();
return selection ? selection.toString() : '';
}
return '';
}
catch (error) {
console.error('[RTE Service] Failed to get selected text:', error);
return '';
}
}
/**
* Check if content is empty
*/
isContentEmpty() {
const content = this.getContent();
if (!content)
return true;
// Create a temporary div to parse HTML
const div = document.createElement('div');
div.innerHTML = content;
// Get text content and clean it
const text = div.textContent?.replace(/\u00A0/g, '').trim() || '';
// Check if only contains empty tags
const cleaned = div.innerHTML
.replace(/<br\s*\/?>/gi, '')
.replace(/<div>(\s| )*<\/div>/gi, '')
.replace(/<p>(\s| )*<\/p>/gi, '')
.replace(/ /gi, '')
.trim();
return !text && cleaned.length === 0;
}
/**
* Get character count
*/
getCharacterCount() {
const content = this.getContent();
if (!content)
return 0;
const div = document.createElement('div');
div.innerHTML = content;
const text = div.textContent?.replace(/\u00A0/g, '').trim() || '';
return text.length;
}
/**
* Get word count
*/
getWordCount() {
const content = this.getContent();
if (!content)
return 0;
const div = document.createElement('div');
div.innerHTML = content;
const text = div.textContent?.replace(/\u00A0/g, '').trim() || '';
if (!text)
return 0;
const words = text.match(/\b\w+\b/g);
return words ? words.length : 0;
}
// Check if editor is readonly
isReadonly() {
return this.currentEditor?.readonly || false;
}
// Check if editor is available
isAvailable() {
return !!this.currentEditor?.editorInstance;
}
/**
* Hide all floating panels (useful for cleanup)
*/
hideFloatingPanels() {
if (this.currentEditor?.hideAllFloatPanels) {
this.currentEditor.hideAllFloatPanels();
}
}
/**
* Removes the last inserted image with a temporary blob or data URL.
*/
removeLastPlaceholderImage() {
if (!this.currentEditor)
return false;
const iframe = this.currentEditor?.editorContainer?.nativeElement?.querySelector('iframe');
const body = iframe?.contentDocument?.body;
if (!body)
return false;
const images = Array.from(body.querySelectorAll('img'));
for (let i = images.length - 1; i >= 0; i--) {
const img = images[i];
if (img.src.startsWith('blob:') || img.src.startsWith('data:')) {
img.parentElement?.removeChild(img);
console.debug('[RTE Service] Removed temporary placeholder image.');
return true;
}
}
return false;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RichTextEditorService, deps: [{ token: RICHTEXTEDITOR_ASSETS_PATH }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RichTextEditorService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RichTextEditorService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}], ctorParameters: () => [{ type: undefined, decorators: [{
type: Inject,
args: [RICHTEXTEDITOR_ASSETS_PATH]
}] }] });
const RTE_TOOLBAR_PRESETS = {
BASIC: 'bold,italic,underline|fontname,fontsize|forecolor,backcolor|removeformat',
STANDARD: 'bold,italic,underline,strikethrough|fontname,fontsize|forecolor,backcolor|removeformat|undo,redo',
FULL: "{bold,italic,underline,forecolor,backcolor}|{justifyleft,justifycenter,justifyright,justifyfull}|{insertorderedlist,insertunorderedlist,indent,outdent}{superscript,subscript}" +
" #{paragraphs:toggle,fontname:toggle,fontsize:toggle,inlinestyle,lineheight}" +
" / {removeformat,cut,copy,paste,delete,find}|{insertlink,unlink,insertblockquote,insertemoji,insertchars,inserttable,insertimage,insertgallery,insertvideo,insertdocument,insertcode}" +
"#{preview,code,selectall}" +
" /{paragraphs:dropdown | fontname:dropdown | fontsize:dropdown} {paragraphstyle,toggle_paragraphop,menu_paragraphop}" +
"#{toggleborder,fullscreenenter,fullscreenexit,undo,redo,togglemore}",
MINIMAL: 'bold,italic|fontsize|forecolor|removeformat'
};
// rich-text-editor-license.token.ts
const RTE_LICENSE_KEY = new InjectionToken('RTE_LICENSE_KEY');
function cleanToolbarString(toolbar) {
let cleaned = toolbar;
// Remove :toggle and :dropdown
cleaned = cleaned.replace(/:toggle/g, '').replace(/:dropdown/g, '');
// Fix spacing and redundancy
cleaned = cleaned
.replace(/,+/g, ',')
.replace(/\{,+/g, '{')
.replace(/,+\}/g, '}')
.replace(/\|+/g, '|')
.replace(/\{\s*\|/g, '{')
.replace(/\|\s*\}/g, '}')
.replace(/\{\s*\}/g, '')
.replace(/\s*,\s*/g, ',')
.replace(/\s*\|\s*/g, '|')
.replace(/\{\s+/g, '{')
.replace(/\s+\}/g, '}');
// Fix tool concatenation issues
cleaned = cleaned.replace(/\b([a-z]),(?=[a-z],|[a-z]\b)/g, '$1');
let previousCleaned = '';
while (previousCleaned !== cleaned) {
previousCleaned = cleaned;
cleaned = cleaned.replace(/\b([a-z]),(?=[a-z],|[a-z]\b)/g, '$1');
}
// Process sections
const sections = cleaned.split(/([/#])/);
const processedSections = [];
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
if (section === '/' || section === '#') {
processedSections.push(section);
continue;
}
if (!section.trim())
continue;
const groups = section.split('|');
const processedGroups = [];
for (let group of groups) {
const hasBraces = group.includes('{') || group.includes('}');
let content = group.replace(/[{}]/g, '').trim();
if (!content)
continue;
// Fix common concatenation issues
content = content
.replace(/(?<=fontname)(?=fontsize)/g, ',')
.replace(/(?<=fontsize)(?=inlinestyle)/g, ',')
.replace(/(?<=inlinestyle)(?=lineheight)/g, ',')
.replace(/(?<=paragraphs)(?=fontname)/g, ',')
.replace(/(?<=paragraphstyle)(?=menu_)/g, ',')
.replace(/underlinefore/g, 'underline,fore')
.replace(/forecolorback/g, 'forecolor,back')
.replace(/backcolor/g, 'backcolor')
.replace(/outdentsuperscript/g, 'outdent,superscript')
.replace(/insertlinkun/g, 'insertlink,un')
.replace(/unlinkinsert/g, 'unlink,insert')
.replace(/insertblockquote/g, 'insertblockquote')
.replace(/inserttable/g, 'inserttable')
.replace(/insertimage/g, 'insertimage')
.replace(/removeformat/g, 'removeformat');
content = content.replace(/,+/g, ',').trim();
if (content) {
processedGroups.push(hasBraces ? `{${content}}` : content);
}
}
if (processedGroups.length > 0) {
processedSections.push(processedGroups.join('|'));
}
}
cleaned = processedSections.join('');
// Final cleanup
cleaned = cleaned
.replace(/\{\s*\}/g, '')
.replace(/\|+/g, '|')
.replace(/\/+/g, '/')
.replace(/#+/g, '#')
.replace(/^[|/#]+|[|/#]+$/g, '')
.replace(/\s+/g, ' ')
.trim();
return cleaned;
}
class EditorEventManager {
editorInstance;
listeners = [];
constructor(editorInstance) {
this.editorInstance = editorInstance;
}
attach(event, handler) {
if (this.editorInstance?.attachEvent) {
this.editorInstance.attachEvent(event, handler);
this.listeners.push({ event, handler });
}
}
attachMany(events, handler) {
events.forEach(event => this.attach(event, handler));
}
detachAll() {
if (this.editorInstance?.detachEvent) {
this.listeners.forEach(({ event, handler }) => {
try {
this.editorInstance.detachEvent(event, handler);
}
catch { }
});
}
this.listeners = [];
}
}
function safeCleanupFloatingPanels() {
try {
const selectors = [
'rte-floatpanel',
'.rte-floatpanel',
'.rte-floatpanel-paragraphop',
'[class*="rte-float"]',
'[class*="rte-popup"]',
'.rte-toolbar-float',
'.rte-dropdown-panel',
];
selectors.forEach((selector) => {
const elements = document.querySelectorAll(selector);
elements.forEach((element) => {
try {
if (element &&
element.parentNode &&
document.body.contains(element)) {
element.parentNode.removeChild(element);
}
}
catch (e) {
if (element instanceof HTMLElement) {
element.style.display = 'none';
element.style.visibility = 'hidden';
}
}
});
});
cleanupOrphanedElements();
}
catch (error) {
// Silent fail
}
}
function cleanupOrphanedElements() {
try {
const rteElements = document.querySelectorAll('[id*="rte_"], [class*="rte_"]');
rteElements.forEach((element) => {
try {
if (!document.body.contains(element)) {
element.remove();
}
}
catch (e) {
// Ignore
}
});
}
catch (e) {
// Silent fail
}
}
/**
* Monkey patch Node.prototype.removeChild to avoid NotFoundError
* when removing already-detached DOM elements.
*/
function patchRemoveChildIfDetached() {
const originalRemoveChild = Node.prototype.removeChild;
Node.prototype.removeChild = function (child) {
if (child && child.parentNode === this) {
return originalRemoveChild.call(this, child);
}
return child;
};
}
function hasRequiredValidator(control) {
if (!control || !control.validator)
return false;
const result = control.validator({ value: null });
return !!(result && result['required']);
}
/**
* Enhanced empty check that considers images and media as content
*/
function isTrulyEmpty(html) {
if (!html || html.trim() === '')
return true;
const div = document.createElement('div');
div.innerHTML = html;
const hasImages = div.querySelectorAll('img').length > 0;
if (hasImages)
return false;
const hasVideos = div.querySelectorAll('video, iframe').length > 0;
if (hasVideos)
return false;
const hasEmbeds = div.querySelectorAll('embed, object, audio').length > 0;
if (hasEmbeds)
return false;
const text = div.textContent?.replace(/\u00A0/g, '').trim() || '';
const cleaned = div.innerHTML
.replace(/<br\s*\/?>/gi, '')
.replace(/<div>(\s| )*<\/div>/gi, '')
.replace(/<p>(\s| )*<\/p>/gi, '')
.replace(/ /gi, '')
.trim();
return !text && cleaned.length === 0;
}
class RichTextEditorComponent {
injector;
rteService;
cdr;
ngZone;
globalLicenseKey;
editorContainer;
licenseKey = '';
config = {};
rtePreset = null;
imageToolbarItems = null;
excludedToolbarItems = [];
initialContent = '';
errorMessages = {
required: 'This field is required.',
};
fileUploadHandler = () => { };
enableImageUpload = false;
enableVideoEmbed = false;
readonly = false;
eventManager = null;
editorInstance;
value = '';
ngControl = null;
changeTimer;
isDestroyed = false;
cleanupAttempts = 0;
eventListeners = [];
domCleanupTimer;
onChange = (value) => { };
onTouched = () => { };
constructor(injector, rteService, cdr, ngZone, globalLicenseKey) {
this.injector = injector;
this.rteService = rteService;
this.cdr = cdr;
this.ngZone = ngZone;
this.globalLicenseKey = globalLicenseKey;
}
ngOnInit() {
patchRemoveChildIfDetached();
this.rteService.setCurrentEditor(this);
try {
this.ngControl = this.injector.get(NgControl);
if (this.ngControl) {
this.ngControl.valueAccessor = this;
}
}
catch {
// Safe fallback
}
}
ngAfterViewInit() {
// Run outside Angular zone to prevent change detection issues
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
if (!this.isDestroyed) {
this.initEditor();
}
}, 100);
});
}
initEditor() {
try {
// Clean up any existing instance first
this.cleanupExistingEditor();
const fullConfig = this.prepareConfiguration();
this._applyCustomStyles();
// Create editor instance
this.editorInstance = new RichTextEditor(this.editorContainer.nativeElement, fullConfig);
// Set initial content
if (this.value) {
this.editorInstance.setHTMLCode(this.value);
}
else if (this.initialContent) {
this.value = this.initialContent;
this.editorInstance.setHTMLCode(this.initialContent);
this.ngZone.run(() => {
this.onChange(this.initialContent);
this.onTouched();
});
}
if (this.readonly && this.editorInstance?.setReadOnly) {
this.editorInstance.setReadOnly(true);
}
this.setupEventListeners();
// Update image toolbar if needed
if (this.imageToolbarItems && this.editorInstance) {
this.updateImageToolbar();
}
}
catch (error) {
console.error('[RTE] Failed to initialize editor:', error);
}
}
cleanupExistingEditor() {
if (this.editorInstance) {
try {
// Remove all event listeners first
this.removeAllEventListeners();
// Try to destroy the editor instance
if (typeof this.editorInstance.destroy === 'function') {
this.editorInstance.destroy();
}
}
catch (e) {
console.warn('[RTE] Error during editor cleanup:', e);
}
this.editorInstance = null;
}
}
setupEventListeners() {
if (!this.editorInstance)
return;
this.eventManager = new EditorEventManager(this.editorInstance);
const triggerUpdate = () => {
if (this.changeTimer)
clearTimeout(this.changeTimer);
this.changeTimer = setTimeout(() => {
if (this.isDestroyed || !this.editorInstance)
return;
this.ngZone.run(() => {
try {
const html = this.editorInstance.getHTMLCode() || '';
this.value = html;
this.onChange(html);
this.onTouched();
if (this.ngControl?.control) {
const finalValue = isTrulyEmpty(html) ? '' : html;
this.ngControl.control.setValue(finalValue, { emitEvent: false });
this.ngControl.control.updateValueAndValidity();
}
}
catch (error) {
console.error('[RTE] Error in update handler:', error);
}
});
}, 150);
};
// Change-related events
this.eventManager.attachMany(['change', 'keyup', 'paste', 'input'], triggerUpdate);
// Blur
this.eventManager.attach('blur', () => {
this.ngZone.run(() => {
this.onTouched();
const control = this.ngControl?.control;
if (control) {
control.markAsTouched();
control.updateValueAndValidity();
}
});
});
// Selection change (image toolbar)
this.eventManager.attach('selectionchange', () => {
setTimeout(() => this.checkImageSelection(), 100);
});
}
removeAllEventListeners() {
this.eventManager?.detachAll();
this.eventManager = null;
}
updateImageToolbar() {
if (this.editorInstance && this.imageToolbarItems) {
const hasSlash = this.imageToolbarItems.includes('/');
let imageToolbarString = '';
if (hasSlash) {
imageToolbarString = this.imageToolbarItems.join('');
}
else {
imageToolbarString = `{${this.imageToolbarItems.join(',')}}`;
}
if (this.editorInstance.config) {
this.editorInstance.config.controltoolbar_IMG = imageToolbarString;
}
try {
if (typeof this.editorInstance.setConfig === 'function') {
this.editorInstance.setConfig('controltoolbar_IMG', imageToolbarString);
}
}
catch (e) {
// Some versions might not have setConfig
}
}
}
checkImageSelection() {
if (!this.editorInstance || this.isDestroyed)
return;
try {
const iframe = this.editorContainer.nativeElement.querySelector('iframe');
if (iframe?.contentWindow && iframe.contentDocument) {
const selection = iframe.contentWindow.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const container = range.commonAncestorContainer;
let imgElement = null;
if (container.nodeType === Node.ELEMENT_NODE) {
imgElement = container.querySelector('img');
}
else if (container.parentElement) {
imgElement = container.parentElement.closest('img');
}
if (imgElement && this.imageToolbarItems) {
this.editorInstance.updateToolbar &&
this.editorInstance.updateToolbar();
}
}
}
}
catch (e) {
// Ignore errors during selection check
}
}
writeValue(value) {
const incomingValue = value ?? this.initialContent ?? '';
this.value = incomingValue;
if (this.editorInstance && !this.isDestroyed) {
const current = this.editorInstance.getHTMLCode() || '';
if (this.normalizeHtml(current) !== this.normalizeHtml(incomingValue)) {
try {
this.editorInstance.setHTMLCode(incomingValue);
}
catch (e) {
console.warn('[RTE] Error setting HTML code:', e);
}
}
}
}
normalizeHtml(html) {
return (html || '')
.replace(/\u00A0/g, '')
.replace(/\s+/g, ' ')
.replace(/<br\s*\/?>/gi, '')
.replace(/<p>\s*<\/p>/gi, '')
.trim();
}
registerOnChange(fn) {
this.onChange = fn;
}
registerOnTouched(fn) {
this.onTouched = fn;
}
setDisabledState(isDisabled) {
const shouldDisable = isDisabled || this.readonly;
if (this.editorInstance?.setReadOnly && !this.isDestroyed) {
try {
this.editorInstance.setReadOnly(shouldDisable);
}
catch (e) {
console.warn('[RTE] Error setting disabled state:', e);
}
}
}
ngOnDestroy() {
this.isDestroyed = true;
// Clear timers
if (this.changeTimer) {
clearTimeout(this.changeTimer);
}
if (this.domCleanupTimer) {
clearTimeout(this.domCleanupTimer);
}
// Clear service reference
this.rteService.clearCurrentEditor();
// Schedule cleanup outside Angular zone
this.ngZone.runOutsideAngular(() => {
// Immediate cleanup attempt
this.performCleanup();
// Schedule additional cleanup attempts
this.domCleanupTimer = setTimeout(() => {
this.performCleanup();
}, 100);
});
}
performCleanup() {
// Remove event listeners
this.removeAllEventListeners();
// Clean up editor instance
if (this.editorInstance) {
try {
if (typeof this.editorInstance.destroy === 'function') {
this.editorInstance.destroy();
}
}
catch (error) {
// Silently ignore destroy errors
}
this.editorInstance = null;
}
// Clean up floating panels with safe DOM manipulation
safeCleanupFloatingPanels();
}
validate(control) {
const value = control?.value || '';
const isEmpty = isTrulyEmpty(value);
if (hasRequiredValidator(control) && isEmpty) {
return { required: true };
}
return null;
}
fixCharacterCount() {
if (!this.editorInstance || this.isDestroyed)
return;
try {
const html = this.editorInstance.getHTMLCode() || '';
const div = document.createElement('div');
div.innerHTML = html;
const text = div.textContent || '';
const count = text.replace(/\u00A0/g, '').trim().length;
const counter = this.editorContainer.nativeElement.querySelector('.character-count');
if (counter) {
counter.textContent = `characters: ${count}`;
}
}
catch (e) {
// Ignore character count errors
}
}
getCharacterCount() {
try {
const html = this.editorInstance?.getHTMLCode?.() || '';
const div = document.createElement('div');
div.innerHTML = html;
const text = div.textContent || '';
return text.replace(/\u00A0/g, '').trim().length;
}
catch (e) {
return 0;
}
}
get showError() {
const control = this.ngControl?.control;
if (!control)
return false;
const isRequired = hasRequiredValidator(control);
return !!(control.invalid &&
control.touched &&
(isRequired || control.errors?.['required']));
}
get currentErrorMessage() {
const errors = this.ngControl?.control?.errors;
if (!errors)
return null;
const firstKey = Object.keys(errors)[0];
return this.errorMessages[firstKey] || 'Invalid field';
}
getMobileExpandedToolbar() {
const basicMobileTools = [
'paragraphs:dropdown',
'paragraphs:toggle',
'fontname:toggle',
'fontsize:toggle',
'bold',
'italic',
'underline',
'fontname',
'fontsize',
'insertlink',
'insertemoji',
'insertimage',
'insertvideo',
'removeformat',
'code',
'toggleborder',
'fullscreenenter',
'fullscreenexit',
'undo',
'redo',
'togglemore',
'fontname:dropdown',
'fontsize:dropdown',
];
if (this.rtePreset && RTE_TOOLBAR_PRESETS[this.rtePreset]) {
let fullToolbar = RTE_TOOLBAR_PRESETS[this.rtePreset];
for (const tool of basicMobileTools) {
const escapedTool = tool.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const toolPattern = new RegExp(`\\b${escapedTool}\\b`, 'g');
fullToolbar = fullToolbar.replace(toolPattern, '');
}
// 🧼 Clean both desktop/mobile exclusions
fullToolbar = this.excludeToolbarItems(fullToolbar);
return fullToolbar || this.getDefaultMobileExpandedToolbar();
}
return this.getDefaultMobileExpandedToolbar();
}
getDefaultMobileExpandedToolbar() {
return `{strike,subscript,superscript}|{forecolor,backcolor}|
{justifyleft,justifycenter,justifyright,justifyfull}|
{insertorderedlist,insertunorderedlist}|{outdent,indent}|
{inserthorizontalrule,insertblockquote,inserttable}|
{cut,copy,paste,pastetext,pasteword}|
{find,replace}|{selectall,print,spellcheck}|{help}`;
}
prepareConfiguration() {
const baseConfig = { ...this.config };
if (!baseConfig.height) {
baseConfig.height = 300;
}
const enhancedConfig = {
...baseConfig,
license: this.globalLicenseKey || this.licenseKey,
enableObjectResizing: true,
enableImageUpload: this.enableImageUpload,
enableVideoEmbed: this.enableVideoEmbed,
file_upload_handler: (file, callback, optionalIndex, optionalFiles) => {
const wrappedCallback = (url, errorCode) => {
if (!url) {
// 🚨 Upload failed — clean up placeholder
this.rteService.removeLastPlaceholderImage();
console.warn('[RTE] Upload failed. Placeholder removed.');
}
callback(url, errorCode);
};
this.fileUploadHandler(file, wrappedCallback, optionalIndex, optionalFiles);
},
content_changed_callback: () => this.fixCharacterCount(),
showFloatingToolbar: false,
forceDesktopMode: true,
disableMobileMode: true,
toolbarModeViewport: 'always-desktop',
showBottomToolbar: false,
contentCssUrl: '',
toolbarMobile: 'basic',
subtoolbar_more_mobile: this.getMobileExpandedToolbar(),
showControlBoxOnImageSelection: true,
enableImageFloatStyle: true,
contentCSSText: `
/* Custom styles */
body {
overflow-y: hidden;
padding: 0px;
margin: 0px;
}
body, table, p, div {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #414141;
font-size: 14px;
line-height: 1.6;
}
img {
cursor: default;
}
`,
};
if (this.imageToolbarItems && Array.isArray(this.imageToolbarItems)) {
const hasSlash = this.imageToolbarItems.includes('/');
let imageToolbarString = '';
if (hasSlash) {
imageToolbarString = this.imageToolbarItems.join('');
}
else {
imageToolbarString = `{${this.imageToolbarItems.join(',')}}`;
}
enhancedConfig.controltoolbar_IMG = imageToolbarString;
enhancedConfig.imagecontrolbar = imageToolbarString;
enhancedConfig.image_toolbar = imageToolbarString;
}
if (this.rtePreset && RTE_TOOLBAR_PRESETS[this.rtePreset]) {
let fullToolbar = RTE_TOOLBAR_PRESETS[this.rtePreset];
fullToolbar = this.excludeToolbarItems(fullToolbar);
enhancedConfig.toolbar = 'custom';
enhancedConfig.toolbar_custom = fullToolbar;
}
return enhancedConfig;
}
_applyCustomStyles() {
if (!document.getElementById('rte-consistent-toolbar-styles')) {
const styleEl = document.createElement('style');
styleEl.id = 'rte-consistent-toolbar-styles';
styleEl.innerHTML = `
/* Custom mobile styles to fix toolbar */
(max-width: 992px) {
.rte-toolbar-desktop,
.rte-toolbar {
display: flex !important;
flex-wrap: wrap !important;
overflow-x: auto !important;
white-space: nowrap !important;
-webkit-overflow-scrolling: touch !important;
max-width: 100% !important;
padding: 4px 0 !important;
}
.rte-toolbar button,
.rte-toolbar .rte-dropdown {
flex-shrink: 0 !important;
min-width: 28px !important;
height: 28px !important;
margin: 2px !important;
}
.rte-toolbar-desktop {
display: flex !important;
}
}
/* Force image toolbar visibility */
.rte-image-controlbox {
display: block !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Prevent orphaned floating panels */
rte-floatpanel {
z-index: 10000;
}
`;
document.head.appendChild(styleEl);
}
}
insertContentAtCursor(content) {
if (this.readonly || this.isDestroyed)
return;
try {
const iframe = this.editorContainer.nativeElement.querySelector('iframe');
if (!iframe?.contentWindow || !iframe.contentDocument) {
console.warn('[RTE] iframe not found or inaccessible');
return;
}
const iframeDoc = iframe.contentDocument;
const editableBody = iframeDoc.body;
if (!editableBody?.isContentEditable) {
console.warn('[RTE] iframe body is not editable');
return;
}
editableBody.focus();
iframeDoc.execCommand('insertHTML', false, content);
const html = this.editorInstance.getHTMLCode();
this.value = html;
this.ngZone.run(() => {
this.onChange(html);
this.onTouched();
if (this.ngControl?.control) {
this.ngControl.control.setValue(html, { emitEvent: false });
this.ngControl.control.updateValueAndValidity();
}
});
}
catch (error) {
console.error('[RTE] Failed to inject content into iframe:', error);
}
}
hideAllFloatPanels() {
safeCleanupFloatingPanels();
}
excludeToolbarItems(toolbar) {
if (!toolbar || !this.excludedToolbarItems?.length)
return toolbar;
for (const tool of this.excludedToolbarItems) {
const escapedTool = tool.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const toolPattern = new RegExp(`\\b${escapedTool}\\b`, 'g');
toolbar = toolbar.replace(toolPattern, '');
}
return cleanToolbarString(toolbar);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RichTextEditorComponent, deps: [{ token: i0.Injector }, { token: RichTextEditorService }, { token: i0.ChangeDetectorRef }, { token: i0.NgZone }, { token: RTE_LICENSE_KEY, optional: true }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: RichTextEditorComponent, selector: "lib-rich-text-editor", inputs: { licenseKey: "licenseKey", config: "config", rtePreset: "rtePreset", imageToolbarItems: "imageToolbarItems", excludedToolbarItems: "excludedToolbarItems", initialContent: "initialContent", errorMessages: "errorMessages", fileUploadHandler: "fileUploadHandler", enableImageUpload: "enableImageUpload", enableVideoEmbed: "enableVideoEmbed", readonly: "readonly" }, providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RichTextEditorComponent),
multi: true,
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => RichTextEditorComponent),
multi: true,
},
], viewQueries: [{ propertyName: "editorContainer", first: true, predicate: ["editorContainer"], descendants: true, static: true }], ngImport: i0, template: `
<div #editorContainer [class.invalid]="showError"></div>
<div class="error-message" *ngIf="showError">
{{ currentErrorMessage }}
</div>
`, isInline: true, styles: [":host{display:block;position:relative}.invalid{border:1px solid red}.error-message{color:red;font-size:12px;margin-top:4px}\n"], dependencies: [{ kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RichTextEditorComponent, decorators: [{
type: Component,
args: [{ selector: 'lib-rich-text-editor', template: `
<div #editorContainer [class.invalid]="showError"></div>
<div class="error-message" *ngIf="showError">
{{ currentErrorMessage }}
</div>
`, providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RichTextEditorComponent),
multi: true,
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => RichTextEditorComponent),
multi: true,
},
], styles: [":host{display:block;position:relative}.invalid{border:1px solid red}.error-message{color:red;font-size:12px;margin-top:4px}\n"] }]
}], ctorParameters: () => [{ type: i0.Injector }, { type: RichTextEditorService }, { type: i0.ChangeDetectorRef }, { type: i0.NgZone }, { type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [RTE_LICENSE_KEY]
}] }], propDecorators: { editorContainer: [{
type: ViewChild,
args: ['editorContainer', { static: true }]
}], licenseKey: [{
type: Input
}], config: [{
type: Input
}], rtePreset: [{
type: Input
}], imageToolbarItems: [{
type: Input
}], excludedToolbarItems: [{
type: Input
}], initialContent: [{
type: Input
}], errorMessages: [{
type: Input
}], fileUploadHandler: [{
type: Input
}], enableImageUpload: [{
type: Input
}], enableVideoEmbed: [{
type: Input
}], readonly: [{
type: Input
}] } });
class RichTextEditorModule {
static forRoot(licenseKey) {
return {
ngModule: RichTextEditorModule,
providers: [
{ provide: RTE_LICENSE_KEY, useValue: licenseKey }
]
};
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RichTextEditorModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "18.2.13", ngImport: i0, type: RichTextEditorModule, declarations: [RichTextEditorComponent], imports: [CommonModule], exports: [RichTextEditorComponent] });
static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RichTextEditorModule, imports: [CommonModule] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RichTextEditorModule, decorators: [{
type: NgModule,
args: [{
declarations: [RichTextEditorComponent],
imports: [CommonModule],
exports: [RichTextEditorComponent]
}]
}] });
/*
* Public API Surface of rich-text-editor
*/
/**
* Generated bundle index. Do not edit.
*/
export { DEFAULT_RICHTEXTEDITOR_ASSETS_PATH, RICHTEXTEDITOR_ASSETS_PATH, RTE_LICENSE_KEY, RTE_TOOLBAR_PRESETS, RichTextEditorComponent, RichTextEditorModule, RichTextEditorService };
//# sourceMappingURL=angular-rich-text-editor.mjs.map