TinyMCE Rich Text Editor Integration Skill
This skill helps integrate TinyMCE rich text editor using ngx-tinymce in Angular.
Core Principles
Rich Text Editing
- WYSIWYG: What You See Is What You Get editing
- Plugins: Extensible with TinyMCE plugins
- Validation: Content validation and sanitization
- Forms Integration: Works with Angular Reactive Forms
Key Features
- Customizable toolbar
- Image upload and management
- Code view and formatting
- Auto-save functionality
- Responsive design
- Accessibility support
Installation
npm install ngx-tinymce@^20.0.0
Basic Configuration
Standalone Component
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TinymceModule } from 'ngx-tinymce';
import { SHARED_IMPORTS } from '@shared';
@Component({
selector: 'app-article-editor',
standalone: true,
imports: [SHARED_IMPORTS, TinymceModule, FormsModule],
template: `
<tinymce
[(ngModel)]="content"
[config]="config"
(ngModelChange)="handleContentChange($event)"
/>
<button nz-button nzType="primary" (click)="save()">
Save Article
</button>
`
})
export class ArticleEditorComponent {
content = signal('');
config = {
height: 500,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap',
'preview', 'anchor', 'searchreplace', 'visualblocks', 'code',
'fullscreen', 'insertdatetime', 'media', 'table', 'help', 'wordcount'
],
toolbar:
'undo redo | formatselect | bold italic backcolor | \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | help'
};
handleContentChange(content: string): void {
this.content.set(content);
}
save(): void {
console.log('Saving content:', this.content());
}
}
Reactive Forms Integration
import { Component, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { TinymceModule } from 'ngx-tinymce';
@Component({
selector: 'app-article-form',
standalone: true,
imports: [SHARED_IMPORTS, TinymceModule, ReactiveFormsModule],
template: `
<form [formGroup]="articleForm" (ngSubmit)="submit()">
<nz-form-item>
<nz-form-label [nzRequired]="true">Title</nz-form-label>
<nz-form-control nzErrorTip="Please enter title">
<input nz-input formControlName="title" placeholder="Article title" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label [nzRequired]="true">Content</nz-form-label>
<nz-form-control nzErrorTip="Please enter content">
<tinymce
formControlName="content"
[config]="config"
/>
</nz-form-control>
</nz-form-item>
<button
nz-button
nzType="primary"
[disabled]="articleForm.invalid"
>
Publish
</button>
</form>
`
})
export class ArticleFormComponent {
private fb = inject(FormBuilder);
articleForm: FormGroup = this.fb.group({
title: ['', [Validators.required, Validators.maxLength(200)]],
content: ['', [Validators.required, Validators.minLength(10)]]
});
config = {
height: 400,
plugins: ['lists', 'link', 'image', 'code', 'table'],
toolbar: 'undo redo | bold italic | bullist numlist | link image'
};
submit(): void {
if (this.articleForm.valid) {
console.log('Article:', this.articleForm.value);
}
}
}
Advanced Configuration
Full-Featured Editor
config = {
height: 600,
menubar: 'file edit view insert format tools table help',
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount',
'codesample', 'emoticons', 'template', 'paste', 'pagebreak'
],
toolbar: [
'undo redo | formatselect | bold italic underline strikethrough | forecolor backcolor',
'alignleft aligncenter alignright alignjustify | outdent indent | numlist bullist',
'link image media codesample | table | code fullscreen preview | emoticons charmap'
],
toolbar_mode: 'sliding',
contextmenu: 'link image table',
skin: 'oxide',
content_css: 'default',
branding: false, // Remove "Powered by TinyMCE"
elementpath: true,
statusbar: true,
resize: true,
autosave_interval: '30s',
autosave_prefix: 'tinymce-autosave-{path}{query}-{id}-',
autosave_restore_when_empty: true,
autosave_retention: '2m'
};
Image Upload
Firebase Storage Integration
import { Storage, ref, uploadBytes, getDownloadURL } from '@angular/fire/storage';
@Component({
selector: 'app-editor-with-upload',
template: `
<tinymce [(ngModel)]="content" [config]="config" />
`
})
export class EditorWithUploadComponent {
private storage = inject(Storage);
content = signal('');
config = {
height: 500,
plugins: ['image', 'link', 'media'],
toolbar: 'image link',
// Custom image upload handler
images_upload_handler: async (blobInfo: any, progress: any) => {
return this.uploadImage(blobInfo.blob(), blobInfo.filename());
},
// Automatic uploads when pasting
automatic_uploads: true,
// Image options
image_advtab: true,
image_caption: true,
image_title: true
};
private async uploadImage(blob: Blob, filename: string): Promise<string> {
try {
// Create storage reference
const storageRef = ref(this.storage, `images/${Date.now()}_${filename}`);
// Upload file
await uploadBytes(storageRef, blob);
// Get download URL
const url = await getDownloadURL(storageRef);
return url;
} catch (error) {
console.error('Image upload failed:', error);
throw error;
}
}
}
Base64 Images (Simple)
config = {
plugins: ['image'],
toolbar: 'image',
// Convert images to base64
images_upload_handler: (blobInfo: any) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blobInfo.blob());
});
}
};
Content Sanitization
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Component({
selector: 'app-article-view',
template: `
<div [innerHTML]="sanitizedContent()"></div>
`
})
export class ArticleViewComponent {
private sanitizer = inject(DomSanitizer);
private content = signal('<p>Article content</p>');
sanitizedContent = computed(() =>
this.sanitizer.sanitize(SecurityContext.HTML, this.content()) || ''
);
}
Custom Plugins
Character Counter Plugin
config = {
plugins: ['wordcount'],
toolbar: 'wordcount',
// Custom setup function
setup: (editor: any) => {
editor.on('init', () => {
console.log('Editor initialized');
});
editor.on('change', () => {
const content = editor.getContent();
console.log('Content changed:', content.length);
});
// Add custom button
editor.ui.registry.addButton('customButton', {
text: 'Custom Action',
onAction: () => {
editor.insertContent(' <strong>Custom content</strong> ');
}
});
}
};
Code Highlighting
config = {
plugins: ['codesample'],
toolbar: 'codesample',
codesample_languages: [
{ text: 'TypeScript', value: 'typescript' },
{ text: 'JavaScript', value: 'javascript' },
{ text: 'Python', value: 'python' },
{ text: 'Java', value: 'java' },
{ text: 'HTML/XML', value: 'markup' },
{ text: 'CSS', value: 'css' },
{ text: 'SQL', value: 'sql' }
],
// Use Prism.js for syntax highlighting
codesample_global_prismjs: true
};
Templates
config = {
plugins: ['template'],
toolbar: 'template',
templates: [
{
title: 'Meeting Notes',
description: 'Template for meeting notes',
content: `
<h2>Meeting Notes</h2>
<p><strong>Date:</strong> </p>
<p><strong>Attendees:</strong> </p>
<h3>Agenda</h3>
<ol>
<li>Item 1</li>
<li>Item 2</li>
</ol>
<h3>Action Items</h3>
<ul>
<li>Action 1</li>
</ul>
`
},
{
title: 'Blog Post',
description: 'Template for blog posts',
content: `
<h1>Blog Title</h1>
<p><em>Published on: </em></p>
<h2>Introduction</h2>
<p>Introduction text...</p>
<h2>Main Content</h2>
<p>Main content...</p>
<h2>Conclusion</h2>
<p>Conclusion...</p>
`
}
]
};
Localization
config = {
language: 'zh_CN', // Chinese Simplified
language_url: '/assets/tinymce/langs/zh_CN.js',
// Or use built-in translations
directionality: 'ltr' // 'ltr' or 'rtl'
};
Mobile Responsive
config = {
// Mobile-friendly toolbar
mobile: {
menubar: false,
toolbar: ['undo', 'redo', 'bold', 'italic']
},
// Desktop toolbar
toolbar: 'undo redo | bold italic underline | alignleft aligncenter alignright',
// Responsive behavior
resize: 'both',
min_height: 300,
max_height: 800
};
Auto-Save
@Component({
selector: 'app-auto-save-editor',
template: `
<tinymce [(ngModel)]="content" [config]="config" />
<div class="save-status">
@if (saveStatus()) {
<nz-tag [nzColor]="saveStatus() === 'saved' ? 'success' : 'warning'">
{{ saveStatus() }}
</nz-tag>
}
</div>
`
})
export class AutoSaveEditorComponent {
content = signal('');
saveStatus = signal<'saving' | 'saved' | null>(null);
config = {
plugins: ['autosave'],
autosave_interval: '30s',
autosave_restore_when_empty: true,
setup: (editor: any) => {
// Listen to auto-save events
editor.on('BeforeAutoSave', () => {
this.saveStatus.set('saving');
});
editor.on('AutoSave', () => {
this.saveContent();
});
}
};
private async saveContent(): Promise<void> {
try {
// Save to backend
await this.articleService.save(this.content());
this.saveStatus.set('saved');
// Clear status after 2 seconds
setTimeout(() => this.saveStatus.set(null), 2000);
} catch (error) {
console.error('Auto-save failed:', error);
}
}
}
Validation
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
/**
* Validate minimum text length (excluding HTML tags)
*/
export function minTextLengthValidator(minLength: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const html = control.value || '';
// Strip HTML tags
const text = html.replace(/<[^>]*>/g, '').trim();
if (text.length < minLength) {
return { minTextLength: { required: minLength, actual: text.length } };
}
return null;
};
}
// Usage
articleForm = this.fb.group({
content: ['', [
Validators.required,
minTextLengthValidator(100) // At least 100 characters of text
]]
});
Accessibility
config = {
// Accessibility options
a11y_advanced_options: true,
// ARIA labels
aria_label: 'Rich text editor',
// Keyboard shortcuts help
plugins: ['help'],
toolbar: 'help',
// Focus management
auto_focus: false,
// Screen reader support
statusbar: true,
elementpath: true
};
Checklist
When integrating TinyMCE:
- Install ngx-tinymce package
- Configure toolbar and plugins
- Implement image upload handler
- Sanitize HTML output
- Add form validation
- Test on mobile devices
- Implement auto-save
- Configure accessibility options
- Test keyboard navigation
- Handle errors gracefully
- Optimize editor performance
- Test with screen readers
Best Practices
✅ DO
// Sanitize content before display
sanitizedContent = computed(() =>
this.sanitizer.sanitize(SecurityContext.HTML, this.content())
);
// Use custom upload handler
config = {
images_upload_handler: (blobInfo) => this.uploadImage(blobInfo)
};
// Validate content
content: ['', [Validators.required, minTextLengthValidator(50)]]
// Auto-save implementation
autosave_interval: '30s'
❌ DON'T
// Don't trust user HTML
<div [innerHTML]="content"></div> // XSS vulnerability!
// Don't use default image upload (base64 bloat)
images_upload_url: '/upload' // Use custom handler instead
// Don't forget accessibility
// Always include aria_label and help plugin