@browser.style/rich-text
Version:
Rich text editor component with customizable toolbar and commands
206 lines (175 loc) • 8.12 kB
JavaScript
import { FormElement } from '../common/form.element.js';
import { commands } from './commands.js';
/**
* RichText
* Rich Text Editor
* @author Mads Stoumann
* @version 1.0.11
* @summary 21-02-2025
* @class RichText
* @extends {FormElement}
*/
export class RichText extends FormElement {
get basePath() {
return new URL('.', import.meta.url).href;
}
constructor() {
super();
}
initializeComponent() {
this.commands = commands.map(command => {
if (command.fn) {
const origFn = command.fn;
command.fn = (...args) => origFn.apply(this, args);
}
return command;
});
this.contentID = `cnt${this.uuid()}`;
this.customToolbarItems = [];
this.eventMode = this.getAttribute('event-mode') || 'both';
this.inputTypes = this.getAttribute('input-types')?.split(',') || ['deleteByContent', 'deleteByCut', 'deleteByDrag', 'deleteContentBackward', 'deleteContentForward', 'deleteEntireSoftLine', 'deleteHardLineBackward', 'deleteHardLineForward', 'deleteSoftLineBackward', 'deleteSoftLineForward', 'deleteWordBackward', 'deleteWordForward', 'formatBackColor', 'formatBold', 'formatFontColor', 'formatFontName', 'formatIndent', 'formatItalic', 'formatJustifyCenter', 'formatJustifyFull', 'formatJustifyLeft', 'formatJustifyRight', 'formatOutdent', 'formatRemove', 'formatSetBlockTextDirection', 'formatSetInlineTextDirection', 'formatStrikethrough', 'formatSubscript', 'formatSuperscript', 'formatUnderline', 'historyRedo', 'historyUndo', 'insertCompositionText', 'insertFromComposition', 'insertFromDrop', 'insertFromPaste', 'insertFromYank', 'insertHorizontalRule', 'insertLineBreak', 'insertLink', 'insertOrderedList', 'insertParagraph', 'insertReplacementText', 'insertText', 'insertTranspose', 'insertUnorderedList'];
this.toolbarItems = this.getAttribute('toolbar')?.split('|') || ['b,i,u'];
this.plaintext = this.hasAttribute('plaintext') || false;
this.initialValue = this.plaintext ? this.textContent : this.innerHTML;
this.root.innerHTML = this.template();
if (this.isFormElement) super.value = this.initialValue;
this.addRefs();
this.addEvents();
}
addEvents() {
this.addEventListener('rt:clear', () => this.resetContent(true));
this.addEventListener('rt:reset', () => this.resetContent(false));
this.content.addEventListener('beforeinput', this.handleBeforeInput.bind(this));
this.content.addEventListener('click', () => this.highlightToolbar());
this.content.addEventListener('input', (e) => {
if (!this.isFormElement) e.stopPropagation();
const content = this.plaintext ? this.content.textContent : this.sanitizeHTML(this.content.innerHTML);
if (this.isFormElement) super.value = content;
this.dispatchEvent(new CustomEvent("rt:content", { detail: { content } }));
});
this.content.addEventListener('keydown', () => this.highlightToolbar());
if (this.toggle) this.toggle.addEventListener('click', this.toggleHTML.bind(this));
this.toolbar.addEventListener('click', this.handleToolbarClick.bind(this));
}
addRefs() {
this.content = this.root.querySelector('[contenteditable]');
this.customToolbar = this.root.querySelector(`[part=custom]`);
this.htmlcode = this.root.querySelector(`[name=htmlcode]`);
this.toggle = this.root.querySelector(`[name=html]`);
this.toolbar = this.root.querySelector(`[part=toolbar]`);
this.highlight = this.commands.filter(command => command.highlight).map(command => command.command);
}
formReset() {
super.value = this.initialValue;
this.setContent(this.initialValue, this.plaintext);
}
addCustomCommand(customCommand) {
if (!this.commands.some(command => command.key === customCommand.key)) {
this.commands.push(customCommand);
this.customToolbarItems.push(customCommand.key);
this.customToolbar.innerHTML = this.customToolbarItems.map((entry) => this.renderToolbarItem(entry)).join('');
} else {
console.error(`Command with key ${customCommand.key} already exists.`);
}
}
handleBeforeInput(event) {
if (!this.inputTypes.includes(event.inputType)) {
event.preventDefault();
}
}
handleToolbarClick(e) {
const node = e.target;
const rule = this.commands.find((item) => item.key === node.name);
if (!rule) return;
if (rule.fn) {
rule.fn(node);
} else {
document.execCommand(rule.command, true, null);
this.highlightToggle(rule.command, node);
}
}
highlightToggle = (command, node) => {
const isActive = document.queryCommandState(command);
node.classList.toggle('--active', isActive);
}
highlightToolbar = () => {
[...this.toolbar.elements].forEach(item => {
if (this.highlight.includes(item.dataset.command)) {
this.highlightToggle(item.dataset.command, item);
}
})
}
renderCommand(obj) {
return `<button type="button" name="${obj.key}"${obj.title ? ` title="${obj.title}"` : ''}${obj.highlight ? ` data-command="${obj.command}"` : ''}>${obj.icon ? this.icon(obj.icon, 'svg') : ''}</button>`;
}
renderInput(obj) {
return obj.inputType ? `<input type="${obj.inputType}" oninput="document.execCommand('${obj.command}', true, this.value)" data-sr>` : '';
}
renderSelect(obj) {
return `<select onchange="document.execCommand('${obj.command}', true, this.value)">${obj.options.split('|').map((option) => {
const [label, value] = option.split(';');
return `<option value="${value}">${label}</option>`;
}).join('')}</select>`;
}
renderSkipToolbar() {
return `<button type="button" part="skip" onclick="document.getElementById('${this.contentID}').focus()">${this.getAttribute('skip-toolbar') || 'Skip to content'}</button>`;
}
renderToolbar() {
return this.toolbarItems.map((group) => `<fieldset>${group.split(',').map((entry) => this.renderToolbarItem(entry)).join('')}</fieldset>`).join('');
}
renderToolbarItem(entry) {
const obj = this.commands.find((item) => item.key === entry) || {};
return obj.options ? this.renderSelect(obj) : this.renderCommand(obj) + this.renderInput(obj);
}
resetContent(clear = false) {
const content = clear ? '' : this.defaultValue;
this.setContent(content, this.plaintext);
if (this.isFormElement) {
super.value = this.plaintext ? this.content.textContent : this.content.innerHTML;
}
}
sanitizeHTML(html) {
return html
.replace(/[\n\t\r]+/g, '') // Remove all newlines, tabs, and carriage returns
.replace(/\s{2,}/g, ' ') // Replace multiple spaces with single space
.replace(/>\s+</g, '><') // Remove whitespace between tags
.replace(/\s+>/g, '>') // Remove whitespace before closing bracket
.replace(/<\s+/g, '<') // Remove whitespace after opening bracket
.replace(/^\s+|\s+$/g, '') // Trim start and end
.replace(/(\S)(<[^>]+>)(\S)/g, '$1$2 $3') // Add space after tag if next char is not space
.replace(/(\S)(<\/[^>]+>)(\S)/g, '$1 $2$3') // Add space before closing tag if prev char is not space
.replace(/\s{2,}/g, ' ') // Clean up any double spaces created
.replace(/<(br|hr)(.*?)>/gi, '<$1>'); // Normalize self-closing tags
}
setContent(content, plaintextOnly = false) {
const stripTags = (input) => input.replace(/<[^>]*>/g, '');
this.setAttribute('plaintext', plaintextOnly);
this.content[plaintextOnly ? 'textContent' : 'innerHTML'] = plaintextOnly ? stripTags(content) : content;
}
template() {
return `
<fieldset part="toolbar">
${this.renderSkipToolbar()}
${this.renderToolbar()}
<fieldset part="custom"></fieldset>
</fieldset>
<textarea name="htmlcode" hidden part="html"></textarea>
<div contenteditable="${this.plaintext ? 'plaintext-only': ''}" style="outline:none;" part="content">${this.initialValue}</div>`;
}
toggleHTML() {
this.htmlcode.hidden = !this.htmlcode.hidden;
this.content.contentEditable = this.htmlcode.hidden;
[...this.toolbar.elements].forEach(item => {
if (item.tagName !== 'FIELDSET' && item.name !== 'html') {
item.disabled = !this.htmlcode.hidden;
}
});
if (this.htmlcode.hidden) {
this.content.innerHTML = this.htmlcode.value;
this.content.dispatchEvent(new Event('input'));
} else {
this.htmlcode.value = this.content.innerHTML;
}
}
}
RichText.register();